Skip to content

Commit c2098a3

Browse files
authored
RSDK-7732 EasyResource mixin (#643)
1 parent 1008d30 commit c2098a3

File tree

9 files changed

+224
-7
lines changed

9 files changed

+224
-7
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ clean:
22
find . -type d -name '__pycache__' | xargs rm -rf
33

44
_lint:
5-
flake8 --exclude=**/gen/**,*_grpc.py,*_pb2.py,*_pb2.pyi,.tox,**/venv/**
5+
flake8 --exclude=**/gen/**,*_grpc.py,*_pb2.py,*_pb2.pyi,.tox,**/venv/**,.direnv
66

77
lint:
88
poetry run $(MAKE) _lint

examples/easy_resource/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

examples/easy_resource/Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist/main: main.py
2+
pyinstaller --onefile --hidden-import="googleapiclient" $^

examples/easy_resource/README.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# easy_resource
2+
3+
This is an example using the `EasyResource` mixin, a helper for creating Viam modules.
4+
5+
## Instructions
6+
7+
### Run standalone in this directory
8+
9+
If you know how to set up a python virtualenv, do that first to give yourself an isolated environment. (If not, it's okay to proceed without it).
10+
11+
```sh
12+
# install the SDK
13+
pip install viam-sdk
14+
# or if you plan to edit the SDK: `pip install -e ../..`
15+
16+
# run the module
17+
./main.py socket-path
18+
19+
# Ctrl-C the running process when you're done
20+
```
21+
22+
Running it this way won't do anything interesting, but it's a quick way to ensure your module can boot.
23+
24+
### Run with viam-server
25+
26+
If you are running viam-server on your laptop, you can use easy_resource as a module.
27+
28+
```sh
29+
# first, build a binary for the module:
30+
pip install pyinstaller
31+
make dist/main
32+
```
33+
34+
Then, in the Viam config builder for your machine:
35+
1. Add a local module with executable path `/PATH/TO/REPO/examples/easy_resource/dist/main`. (Replace `/PATH/TO/REPO`).
36+
1. Then add a local component with type 'sensor' and triplet 'viam:sensor:easy-resource-example' (see screenshot).
37+
1. Save, then go to the control page to interact with the sensor.
38+
39+
Screenshot of 'local component' screen:
40+
41+
![Screenshot showing type and triplet of local component](./add-sensor.png)

examples/easy_resource/add-sensor.png

25.5 KB
Loading

examples/easy_resource/main.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
from viam.components.sensor import Sensor
5+
from viam.resource.easy_resource import EasyResource
6+
from viam.module.module import Module
7+
8+
9+
class MySensor(Sensor, EasyResource):
10+
MODEL = "viam:sensor:easy-resource-example"
11+
12+
async def get_readings(self, **kwargs):
13+
return {"ok": True}
14+
15+
16+
if __name__ == '__main__':
17+
asyncio.run(Module.run_from_registry())

src/viam/module/module.py

+44-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import argparse
12
import io
3+
import logging as pylogging
24
import sys
35
from inspect import iscoroutinefunction
46
from threading import Lock
@@ -35,6 +37,16 @@
3537
LOGGER = logging.getLogger(__name__)
3638

3739

40+
def _parse_module_args() -> argparse.Namespace:
41+
"""
42+
Parse command-line args. Used by the various `Module` entrypoints.
43+
"""
44+
p = argparse.ArgumentParser(description="Start this viam python module")
45+
p.add_argument('socket_path', help="path where this module will serve a unix socket")
46+
p.add_argument('--log-level', type=lambda name: pylogging._nameToLevel[name.upper()], default=logging.INFO)
47+
return p.parse_args()
48+
49+
3850
class Module:
3951
_address: str
4052
_parent_address: Optional[str] = None
@@ -57,12 +69,38 @@ def from_args(cls) -> Self:
5769
Returns:
5870
Module: a new Module instance
5971
"""
60-
args = sys.argv
61-
if len(args) < 2:
62-
raise Exception("Need socket path as command line argument")
63-
address = args[1]
64-
log_level = logging.DEBUG if (len(args) == 3 and "=debug" in args[2].lower()) else logging.INFO
65-
return cls(address, log_level=log_level)
72+
args = _parse_module_args()
73+
return cls(args.socket_path, log_level=args.log_level)
74+
75+
@classmethod
76+
async def run_with_models(cls, *models: ResourceBase):
77+
"""
78+
Module entrypoint that takes a list of ResourceBase implementations.
79+
In most cases you'll want to use run_from_registry instead (see below).
80+
"""
81+
module = cls.from_args()
82+
for model in models:
83+
if not hasattr(model, 'MODEL'):
84+
raise TypeError(f"missing MODEL field on {model}. Resource implementations must define MODEL")
85+
module.add_model_from_registry(model.SUBTYPE, model.MODEL) # pyright: ignore [reportAttributeAccessIssue]
86+
await module.start()
87+
88+
@classmethod
89+
async def run_from_registry(cls):
90+
"""
91+
Module entrypoint that automatically includes all the resources you've created in your program.
92+
93+
Example:
94+
95+
if __name__ == '__main__':
96+
asyncio.run(Module.run_from_registry())
97+
98+
Full example at examples/easy_resource/main.py.
99+
"""
100+
module = cls.from_args()
101+
for key in Registry.REGISTERED_RESOURCE_CREATORS().keys():
102+
module.add_model_from_registry(*key.split('/')) # pyright: ignore [reportArgumentType]
103+
await module.start()
66104

67105
def __init__(self, address: str, *, log_level: int = logging.INFO) -> None:
68106
# When a module is launched by viam-server, its stdout is not connected to a tty. In

src/viam/resource/easy_resource.py

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import re
2+
from typing import Mapping, Union
3+
4+
from viam.proto.app.robot import ComponentConfig
5+
from viam.proto.common import ResourceName
6+
7+
from .. import logging
8+
from .base import ResourceBase
9+
from .types import Model, ModelFamily, Subtype
10+
from .registry import Registry, ResourceCreatorRegistration
11+
12+
modelRegex = re.compile(r"^([^:]+):([^:]+):([^:]+)$")
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def _parse_model(orig: Union[str, Model]) -> Model:
18+
"take a model or string and turn it into a Model"
19+
if isinstance(orig, Model):
20+
return orig
21+
match = modelRegex.match(orig)
22+
if not match:
23+
raise ValueError(f"MODEL {orig} doesn't match expected format 'org:type:name'")
24+
*family, name = match.groups()
25+
return Model(ModelFamily(*family), name)
26+
27+
28+
class EasyResource:
29+
"""
30+
EasyResource is a mixin that simplifies the process of creating Viam modules (extension programs)
31+
and resources (the resource classes provided by those extension programs).
32+
33+
Basic usage:
34+
35+
class MyModel(Sensor, EasyResource):
36+
MODEL = "my-org:sensor:my-sensor"
37+
38+
async def get_readings(self, **kwargs):
39+
return {"ok": True}
40+
41+
See examples/easy_resource/main.py for extended usage.
42+
"""
43+
SUBTYPE: Subtype
44+
MODEL: Model
45+
46+
def __init_subclass__(cls, register=True, **kwargs):
47+
"""
48+
When you subclass this mixin, it parses cls.MODEL and registers cls in global registry.
49+
"""
50+
super().__init_subclass__(**kwargs)
51+
if not hasattr(cls, "MODEL"):
52+
raise ValueError("please define a MODEL like 'org:type:name' on your class, for example 'viam:camera:IMX219'")
53+
cls.MODEL = _parse_model(cls.MODEL)
54+
if register:
55+
cls.register()
56+
57+
def __init__(self, name: str):
58+
# note: this mirrors the constructor for ComponentBase and ServiceBase.
59+
self.name = name
60+
61+
@classmethod
62+
def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):
63+
"""
64+
This is passed to register_resource_creator; the default implementation calls reconfigure()
65+
when an instance of your model is instantiated. You can override this in your subclass.
66+
"""
67+
self = cls(config.name)
68+
logger.debug("created %s %s %s", self.SUBTYPE, self.MODEL, config.name)
69+
self.reconfigure(config, dependencies)
70+
return self
71+
72+
@classmethod
73+
def register(cls):
74+
"""
75+
This adds the model to the global registry. It is called by __init_subclass__ and you typically
76+
won't call it directly.
77+
"""
78+
logger.debug('registering %s %s', cls.SUBTYPE, cls.MODEL)
79+
# note: We could fix this pyright-ignore if EasyResource inherited ResourceBase, but that crashes in the mro()
80+
# walk in ResourceManager.register.
81+
Registry.register_resource_creator(
82+
cls.SUBTYPE, cls.MODEL, ResourceCreatorRegistration(cls.new)) # pyright: ignore [reportArgumentType]
83+
84+
def reconfigure(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):
85+
logger.debug('reconfigure %s %s', self.SUBTYPE, self.MODEL)

tests/test_easy_resource.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
from viam.components.generic import Generic
4+
from viam.proto.app.robot import ComponentConfig
5+
from viam.resource.easy_resource import _parse_model, EasyResource
6+
from viam.resource.registry import Registry
7+
from viam.resource.types import Model, ModelFamily
8+
9+
10+
@pytest.fixture
11+
def clear_registry(monkeypatch):
12+
"helper to patch registry global state for duration of test"
13+
monkeypatch.setattr(Registry, '_SUBTYPES', {})
14+
monkeypatch.setattr(Registry, '_RESOURCES', {})
15+
16+
17+
class TestEasyResource:
18+
def test_parse_model(self):
19+
model = Model(ModelFamily('viam', 'type'), 'name')
20+
assert _parse_model('viam:type:name') == model
21+
assert _parse_model(model) == model
22+
with pytest.raises(ValueError):
23+
_parse_model('not parseable')
24+
25+
def test_subclass(self, clear_registry):
26+
class SubclassTest(Generic, EasyResource):
27+
MODEL = "org:type:name"
28+
# did it register correctly:
29+
assert set(Registry._RESOURCES.keys()) == {f'rdk:component:generic/{SubclassTest.MODEL}'}
30+
31+
# can it be instantiated:
32+
resource = SubclassTest.new(ComponentConfig(name="hello"), {})
33+
assert resource.name == "hello"

0 commit comments

Comments
 (0)