Skip to content

RSDK-7732 EasyResource mixin #643

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ clean:
find . -type d -name '__pycache__' | xargs rm -rf

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

lint:
poetry run $(MAKE) _lint
Expand Down
1 change: 1 addition & 0 deletions examples/easy_resource/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
2 changes: 2 additions & 0 deletions examples/easy_resource/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/main: main.py
pyinstaller --onefile --hidden-import="googleapiclient" $^
41 changes: 41 additions & 0 deletions examples/easy_resource/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# easy_resource

This is an example using the `EasyResource` mixin, a helper for creating Viam modules.

## Instructions

### Run standalone in this directory

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).

```sh
# install the SDK
pip install viam-sdk
# or if you plan to edit the SDK: `pip install -e ../..`

# run the module
./main.py socket-path

# Ctrl-C the running process when you're done
```

Running it this way won't do anything interesting, but it's a quick way to ensure your module can boot.

### Run with viam-server

If you are running viam-server on your laptop, you can use easy_resource as a module.

```sh
# first, build a binary for the module:
pip install pyinstaller
make dist/main
```

Then, in the Viam config builder for your machine:
1. Add a local module with executable path `/PATH/TO/REPO/examples/easy_resource/dist/main`. (Replace `/PATH/TO/REPO`).
1. Then add a local component with type 'sensor' and triplet 'viam:sensor:easy-resource-example' (see screenshot).
1. Save, then go to the control page to interact with the sensor.

Screenshot of 'local component' screen:

![Screenshot showing type and triplet of local component](./add-sensor.png)
Binary file added examples/easy_resource/add-sensor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions examples/easy_resource/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env python3

import asyncio
from viam.components.sensor import Sensor
from viam.resource.easy_resource import EasyResource
from viam.module.module import Module


class MySensor(Sensor, EasyResource):
MODEL = "viam:sensor:easy-resource-example"

async def get_readings(self, **kwargs):
return {"ok": True}


if __name__ == '__main__':
asyncio.run(Module.run_from_registry())
50 changes: 44 additions & 6 deletions src/viam/module/module.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import argparse
import io
import logging as pylogging
import sys
from inspect import iscoroutinefunction
from threading import Lock
Expand Down Expand Up @@ -35,6 +37,16 @@
LOGGER = logging.getLogger(__name__)


def _parse_module_args() -> argparse.Namespace:
"""
Parse command-line args. Used by the various `Module` entrypoints.
"""
p = argparse.ArgumentParser(description="Start this viam python module")
p.add_argument('socket_path', help="path where this module will serve a unix socket")
p.add_argument('--log-level', type=lambda name: pylogging._nameToLevel[name.upper()], default=logging.INFO)
return p.parse_args()


class Module:
_address: str
_parent_address: Optional[str] = None
Expand All @@ -57,12 +69,38 @@ def from_args(cls) -> Self:
Returns:
Module: a new Module instance
"""
args = sys.argv
if len(args) < 2:
raise Exception("Need socket path as command line argument")
address = args[1]
log_level = logging.DEBUG if (len(args) == 3 and "=debug" in args[2].lower()) else logging.INFO
return cls(address, log_level=log_level)
args = _parse_module_args()
return cls(args.socket_path, log_level=args.log_level)

@classmethod
async def run_with_models(cls, *models: ResourceBase):
"""
Module entrypoint that takes a list of ResourceBase implementations.
In most cases you'll want to use run_from_registry instead (see below).
"""
module = cls.from_args()
for model in models:
if not hasattr(model, 'MODEL'):
raise TypeError(f"missing MODEL field on {model}. Resource implementations must define MODEL")
module.add_model_from_registry(model.SUBTYPE, model.MODEL) # pyright: ignore [reportAttributeAccessIssue]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResourceBase does not have a MODEL field at all, since MODELs are for implementation of a ResourceBase, and ResourceBase is for creating the resource definition/interface. This would fail in the possible event that someone passes Arm as the param instead of MyModuleArm

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So would be useful to maybe do a hasattr here and fail if someone doesn't pass the correct type. And also add that disclaimer to the docstring

Copy link
Member Author

@abe-winter abe-winter Jun 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added hasattr check w/ helpful error

I didn't expand docstring because when I started writing it I was saying 'the provided resources have to be complete + runnable' and it felt weird. but can add something if you think it's helpful

await module.start()

@classmethod
async def run_from_registry(cls):
"""
Module entrypoint that automatically includes all the resources you've created in your program.

Example:

if __name__ == '__main__':
asyncio.run(Module.run_from_registry())

Full example at examples/easy_resource/main.py.
"""
module = cls.from_args()
for key in Registry.REGISTERED_RESOURCE_CREATORS().keys():
module.add_model_from_registry(*key.split('/')) # pyright: ignore [reportArgumentType]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dislike the disable pyright here but also seems dumb to create a Subtype just to stringify it again in the next method call. No action needed, just writing it down

await module.start()

def __init__(self, address: str, *, log_level: int = logging.INFO) -> None:
# When a module is launched by viam-server, its stdout is not connected to a tty. In
Expand Down
85 changes: 85 additions & 0 deletions src/viam/resource/easy_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import re
from typing import Mapping, Union

from viam.proto.app.robot import ComponentConfig
from viam.proto.common import ResourceName

from .. import logging
from .base import ResourceBase
from .types import Model, ModelFamily, Subtype
from .registry import Registry, ResourceCreatorRegistration

modelRegex = re.compile(r"^([^:]+):([^:]+):([^:]+)$")

logger = logging.getLogger(__name__)


def _parse_model(orig: Union[str, Model]) -> Model:
"take a model or string and turn it into a Model"
if isinstance(orig, Model):
return orig
match = modelRegex.match(orig)
if not match:
raise ValueError(f"MODEL {orig} doesn't match expected format 'org:type:name'")
*family, name = match.groups()
return Model(ModelFamily(*family), name)


class EasyResource:
"""
EasyResource is a mixin that simplifies the process of creating Viam modules (extension programs)
and resources (the resource classes provided by those extension programs).

Basic usage:

class MyModel(Sensor, EasyResource):
MODEL = "my-org:sensor:my-sensor"

async def get_readings(self, **kwargs):
return {"ok": True}

See examples/easy_resource/main.py for extended usage.
"""
SUBTYPE: Subtype
MODEL: Model

def __init_subclass__(cls, register=True, **kwargs):
"""
When you subclass this mixin, it parses cls.MODEL and registers cls in global registry.
"""
super().__init_subclass__(**kwargs)
if not hasattr(cls, "MODEL"):
raise ValueError("please define a MODEL like 'org:type:name' on your class, for example 'viam:camera:IMX219'")
cls.MODEL = _parse_model(cls.MODEL)
if register:
cls.register()

def __init__(self, name: str):
# note: this mirrors the constructor for ComponentBase and ServiceBase.
self.name = name

@classmethod
def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):
"""
This is passed to register_resource_creator; the default implementation calls reconfigure()
when an instance of your model is instantiated. You can override this in your subclass.
"""
self = cls(config.name)
logger.debug("created %s %s %s", self.SUBTYPE, self.MODEL, config.name)
self.reconfigure(config, dependencies)
return self

@classmethod
def register(cls):
"""
This adds the model to the global registry. It is called by __init_subclass__ and you typically
won't call it directly.
"""
logger.debug('registering %s %s', cls.SUBTYPE, cls.MODEL)
# note: We could fix this pyright-ignore if EasyResource inherited ResourceBase, but that crashes in the mro()
# walk in ResourceManager.register.
Registry.register_resource_creator(
cls.SUBTYPE, cls.MODEL, ResourceCreatorRegistration(cls.new)) # pyright: ignore [reportArgumentType]

def reconfigure(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):
logger.debug('reconfigure %s %s', self.SUBTYPE, self.MODEL)
33 changes: 33 additions & 0 deletions tests/test_easy_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pytest

from viam.components.generic import Generic
from viam.proto.app.robot import ComponentConfig
from viam.resource.easy_resource import _parse_model, EasyResource
from viam.resource.registry import Registry
from viam.resource.types import Model, ModelFamily


@pytest.fixture
def clear_registry(monkeypatch):
"helper to patch registry global state for duration of test"
monkeypatch.setattr(Registry, '_SUBTYPES', {})
monkeypatch.setattr(Registry, '_RESOURCES', {})


class TestEasyResource:
def test_parse_model(self):
model = Model(ModelFamily('viam', 'type'), 'name')
assert _parse_model('viam:type:name') == model
assert _parse_model(model) == model
with pytest.raises(ValueError):
_parse_model('not parseable')

def test_subclass(self, clear_registry):
class SubclassTest(Generic, EasyResource):
MODEL = "org:type:name"
# did it register correctly:
assert set(Registry._RESOURCES.keys()) == {f'rdk:component:generic/{SubclassTest.MODEL}'}

# can it be instantiated:
resource = SubclassTest.new(ComponentConfig(name="hello"), {})
assert resource.name == "hello"
Loading