Skip to content

Rsdk 2071 support modular validation and implicit dependencies #244

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
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
118695a
add ValidateConfig to modules and validator types to registry
purplenicole730 Mar 23, 2023
0ccdd61
add implicit dependencies to gizmo module
purplenicole730 Mar 23, 2023
d05a12c
add validation error capabilities
purplenicole730 Mar 27, 2023
4f9e2d8
make gizmo dependent on a motor
purplenicole730 Mar 27, 2023
7858e5c
raise error if motor is not specified for gizmo
purplenicole730 Mar 27, 2023
8b3813c
Add details on how to use validate_config
purplenicole730 Mar 27, 2023
7aca20d
put comment in example gizmo and not test
purplenicole730 Mar 27, 2023
34928ca
make error a grpc error
purplenicole730 Mar 27, 2023
20fd6b1
Include motor in gizmo init
purplenicole730 Mar 28, 2023
c29269e
Fix comments and types
purplenicole730 Mar 28, 2023
f8dcaa8
fix comments and types
purplenicole730 Mar 28, 2023
e85d307
add arg1 as a requirement in gizmo's validation
purplenicole730 Mar 28, 2023
3649ddd
return no-op as default validator
purplenicole730 Mar 28, 2023
75c27f4
fix errors and add one more test
purplenicole730 Mar 28, 2023
570b705
ignore black error
purplenicole730 Mar 28, 2023
540c83e
fix typo
purplenicole730 Mar 29, 2023
8af7b98
register validator with registering resource creator
purplenicole730 Mar 29, 2023
d833caa
make a resourcecreatorregistration dataclass
purplenicole730 Mar 31, 2023
6e723b9
test if module without validator still passes
purplenicole730 Mar 31, 2023
5b14197
Add comments in ResourceCreatorRegistration
purplenicole730 Mar 31, 2023
0a0255e
update resourcecreatorregistration comment
purplenicole730 Mar 31, 2023
27edfbf
add checks into register_* functions
purplenicole730 Mar 31, 2023
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
5 changes: 3 additions & 2 deletions examples/module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The definition of the new resources are in the `src` directory. Within this dire

The `proto` directory contains the `gizmo.proto` and `summation.proto` definitions of all the message types and calls that can be made to the Gizmo component and Summation service. It also has the compiled python output of the protobuf definition.

The `gizmo` directory contains all the necessary definitions for creating a custom `Gizmo` component type. The `api.py` file defines what a `Gizmo` can do (mirroring the `proto` definition), implements the gRPC `GizmoService` for receiving calls, and the gRPC `GizmoClient` for making calls. See the [API docs](https://docs.viam.com/program/extend/modular-resources/#apis) for more info. The `my_gizmo.py` file in contains the unique implementation of a `Gizmo`. This is defined as a specific `Model`. See the [Model docs](https://docs.viam.com/program/extend/modular-resources/#models) for more info.
The `gizmo` directory contains all the necessary definitions for creating a custom `Gizmo` component type. The `api.py` file defines what a `Gizmo` can do (mirroring the `proto` definition), implements the gRPC `GizmoService` for receiving calls, and the gRPC `GizmoClient` for making calls. See the [API docs](https://docs.viam.com/program/extend/modular-resources/#apis) for more info. The `my_gizmo.py` file in contains the unique implementation of a `Gizmo`. This is defined as a specific `Model`. See the [Model docs](https://docs.viam.com/program/extend/modular-resources/#models) for more info. This implementation uses the `validate_config` function to specify an implicit dependency on a `motor` by default and throw an error if there is an `invalid` attribute.

Similarly, the `summation` directory contains the analogous definitions for the `Summation` service type. The files in this directory mirror the files in the `gizmo` directory.

Expand All @@ -38,7 +38,8 @@ An example configuration for a Gizmo component and a Summation service could loo
"namespace": "acme",
"model": "acme:demo:mygizmo",
"attributes": {
"arg1": "arg1"
"arg1": "arg1",
"motor": "motor1"
},
"depends_on": []
}
Expand Down
5 changes: 3 additions & 2 deletions examples/module/src/gizmo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
This file registers the Gizmo subtype with the Viam Registry, as well as the specific MyGizmo model.
"""

from viam.resource.registry import ResourceRegistration, Registry
from viam.components.motor import * # noqa: F403 Need to import motor so the component registers itself
from viam.resource.registry import Registry, ResourceCreatorRegistration, ResourceRegistration

from .api import Gizmo, GizmoClient, GizmoService
from .my_gizmo import MyGizmo

Registry.register_subtype(ResourceRegistration(Gizmo, GizmoService, lambda name, channel: GizmoClient(name, channel)))

Registry.register_resource_creator(Gizmo.SUBTYPE, MyGizmo.MODEL, MyGizmo.new)
Registry.register_resource_creator(Gizmo.SUBTYPE, MyGizmo.MODEL, ResourceCreatorRegistration(MyGizmo.new, MyGizmo.validate_config))
15 changes: 15 additions & 0 deletions examples/module/src/gizmo/my_gizmo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
gizmo.my_arg = config.attributes.fields["arg1"].string_value
return gizmo

@classmethod
def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
# Custom validation can be done by specifiying a validate function like this one. Validate functions
# can raise errors that will be returned to the parent through gRPC. Validate functions can
# also return a sequence of strings representing the implicit dependencies of the resource.
if "invalid" in config.attributes.fields:
raise Exception(f"'invalid' attribute not allowed for model {cls.SUBTYPE}:{cls.MODEL}")
arg1 = config.attributes.fields["arg1"].string_value
if arg1 == "":
raise Exception("A arg1 attribute is required for Gizmo component.")
motor = [config.attributes.fields["motor"].string_value]
if motor == [""]:
raise Exception("A motor is required for Gizmo component.")
return motor

async def do_one(self, arg1: str, **kwargs) -> bool:
return arg1 == self.my_arg

Expand Down
4 changes: 2 additions & 2 deletions examples/module/src/summation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
This file registers the Summation subtype with the Viam Registry, as well as the specific MySummation model.
"""

from viam.resource.registry import Registry, ResourceRegistration
from viam.resource.registry import Registry, ResourceCreatorRegistration, ResourceRegistration

from .api import SummationClient, SummationRPCService, SummationService
from .my_summation import MySummationService

Registry.register_subtype(ResourceRegistration(SummationService, SummationRPCService, lambda name, channel: SummationClient(name, channel)))

Registry.register_resource_creator(SummationService.SUBTYPE, MySummationService.MODEL, MySummationService.new)
Registry.register_resource_creator(SummationService.SUBTYPE, MySummationService.MODEL, ResourceCreatorRegistration(MySummationService.new))
10 changes: 10 additions & 0 deletions src/viam/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,13 @@ class NotSupportedError(ViamGRPCError):
def __init__(self, message: str):
self.message = message
self.grpc_code = Status.UNIMPLEMENTED


class ValidationError(ViamGRPCError):
"""
Exception raised when there is an error during module validation
"""

def __init__(self, message: str):
self.message = message
self.grpc_code = Status.INVALID_ARGUMENT
15 changes: 14 additions & 1 deletion src/viam/module/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from viam import logging
from viam.components.component_base import ComponentBase
from viam.errors import ResourceNotFoundError
from viam.errors import ResourceNotFoundError, ValidationError
from viam.proto.app.robot import ComponentConfig
from viam.proto.module import (
AddResourceRequest,
Expand All @@ -16,6 +16,8 @@
ReadyResponse,
ReconfigureResourceRequest,
RemoveResourceRequest,
ValidateConfigRequest,
ValidateConfigResponse,
)
from viam.proto.robot import ResourceRPCSubtype
from viam.resource.base import ResourceBase
Expand Down Expand Up @@ -176,3 +178,14 @@ def add_model_from_registry(self, subtype: Subtype, model: Model):
Registry.lookup_resource_creator(subtype, model)
except ResourceNotFoundError:
raise ValueError(f"Cannot add model because it has not been registered. Subtype: {subtype}. Model: {model}")

async def validate_config(self, request: ValidateConfigRequest) -> ValidateConfigResponse:
config: ComponentConfig = request.config
subtype = Subtype.from_string(config.api)
model = Model.from_string(config.model)
validator = Registry.lookup_validator(subtype, model)
try:
dependencies = validator(config)
return ValidateConfigResponse(dependencies=dependencies)
except Exception as e:
raise ValidationError(f"{type(Exception)}: {e}").grpc_error
7 changes: 5 additions & 2 deletions src/viam/module/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from grpclib.server import Stream

from viam.errors import MethodNotImplementedError
from viam.proto.module import (
AddResourceRequest,
AddResourceResponse,
Expand Down Expand Up @@ -52,4 +51,8 @@ async def Ready(self, stream: Stream[ReadyRequest, ReadyResponse]) -> None:
await stream.send_message(response)

async def ValidateConfig(self, stream: Stream[ValidateConfigRequest, ValidateConfigResponse]) -> None:
raise MethodNotImplementedError("ValidateConfig").grpc_error
request = await stream.recv_message()
assert request is not None
response = await self._module.validate_config(request)
if response is not None:
await stream.send_message(response)
72 changes: 59 additions & 13 deletions src/viam/resource/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
from google.protobuf.struct_pb2 import Struct
from grpclib.client import Channel

from viam.errors import DuplicateResourceError, ResourceNotFoundError
from viam.errors import DuplicateResourceError, ResourceNotFoundError, ValidationError
from viam.proto.robot import Status

from .base import ResourceBase

if TYPE_CHECKING:
from .rpc_service_base import ResourceRPCServiceBase
from .types import Model, ResourceCreator, Subtype
from .types import Model, ResourceCreator, Subtype, Validator

Resource = TypeVar("Resource", bound=ResourceBase)

Expand All @@ -32,6 +32,25 @@ async def default_create_status(resource: ResourceBase) -> Status:
return Status(name=resource.get_resource_name(resource.name), status=Struct())


@dataclass
class ResourceCreatorRegistration:
"""An object representing a resource creator to be registered.

If creating a custom Resource creator, you should register the creator by creating a ``ResourceCreatorRegistration`` object and
registering it to the ``Registry``.
"""

creator: "ResourceCreator"
"""A function that can create a resource given a mapping of dependencies (``ResourceName`` to ``ResourceBase``
"""

validator: "Validator" = lambda x: []
"""A function that can validate a resource and return implicit dependencies.

If called without a validator function, default to a function returning an empty Sequence
"""


@dataclass
class ResourceRegistration(Generic[Resource]):
"""An object representing a resource to be registered.
Expand Down Expand Up @@ -75,7 +94,7 @@ class Registry:
"""

_SUBTYPES: ClassVar[Dict["Subtype", ResourceRegistration]] = {}
_RESOURCES: ClassVar[Dict[str, "ResourceCreator"]] = {}
_RESOURCES: ClassVar[Dict[str, ResourceCreatorRegistration]] = {}
_lock: ClassVar[Lock] = Lock()

@classmethod
Expand All @@ -87,30 +106,39 @@ def register_subtype(cls, registration: ResourceRegistration[Resource]):

Raises:
DuplicateResourceError: Raised if the Subtype to register is already in the registry
ValidationError: Raised if registration is missing any necessary parameters
"""
with cls._lock:
if registration.resource_type.SUBTYPE in cls._SUBTYPES:
raise DuplicateResourceError(str(registration.resource_type.SUBTYPE))
cls._SUBTYPES[registration.resource_type.SUBTYPE] = registration

if registration.resource_type and registration.rpc_service and registration.create_rpc_client:
cls._SUBTYPES[registration.resource_type.SUBTYPE] = registration
else:
raise ValidationError("Passed resource registration does not have correct parameters")

@classmethod
def register_resource_creator(cls, subtype: "Subtype", model: "Model", creator: "ResourceCreator"):
"""Register a specific ``Model`` for the specific resource ``Subtype`` with the Registry
def register_resource_creator(cls, subtype: "Subtype", model: "Model", registration: ResourceCreatorRegistration):
"""Register a specific ``Model`` and validator function for the specific resource ``Subtype`` with the Registry

Args:
subtype (Subtype): The Subtype of the resource
model (Model): The Model of the resource
creator (ResourceCreator): A function that can create a resource given a mapping of dependencies (``ResourceName`` to
``ResourceBase``).
registration (ResourceCreatorRegistration): The registration functions of the model

Raises:
DuplicateResourceError: Raised if the Subtype and Model pairing is already registered
ValidationError: Raised if registration does not have creator
"""
key = f"{subtype}/{model}"
with cls._lock:
if key in cls._RESOURCES:
raise DuplicateResourceError(key)
cls._RESOURCES[key] = creator

if registration.creator:
cls._RESOURCES[key] = registration
else:
raise ValidationError("A creator function was not provided")

@classmethod
def lookup_subtype(cls, subtype: "Subtype") -> ResourceRegistration:
Expand Down Expand Up @@ -147,10 +175,28 @@ def lookup_resource_creator(cls, subtype: "Subtype", model: "Model") -> "Resourc
"""
with cls._lock:
try:
return cls._RESOURCES[f"{subtype}/{model}"]
return cls._RESOURCES[f"{subtype}/{model}"].creator
except KeyError:
raise ResourceNotFoundError(subtype.resource_type, subtype.resource_subtype)

@classmethod
def lookup_validator(cls, subtype: "Subtype", model: "Model") -> "Validator":
"""Lookup and retrieve a registered validator function by its subtype and model. If there is none, return None

Args:
subtype (Subtype): The Subtype of the resource
model (Model): The Model of the resource

Returns:
Validator: The function to validate the resource
"""
try:
return cls._RESOURCES[f"{subtype}/{model}"].validator
except AttributeError:
return lambda x: []
except KeyError:
raise ResourceNotFoundError(subtype.resource_type, subtype.resource_subtype)

@classmethod
def REGISTERED_SUBTYPES(cls) -> Mapping["Subtype", ResourceRegistration]:
"""The dictionary of all registered resources
Expand All @@ -164,13 +210,13 @@ def REGISTERED_SUBTYPES(cls) -> Mapping["Subtype", ResourceRegistration]:
return cls._SUBTYPES.copy()

@classmethod
def REGISTERED_RESOURCE_CREATORS(cls) -> Mapping[str, "ResourceCreator"]:
def REGISTERED_RESOURCE_CREATORS(cls) -> Mapping[str, "ResourceCreatorRegistration"]:
"""The dictionary of all registered resources
- Key: subtype/model
- Value: The ResourceCreator for the resource
- Value: The ResourceCreatorRegistration for the resource

Returns:
Mapping[str, ResourceCreator]: All registered resources
Mapping[str, ResourceCreatorRegistration]: All registered resources
"""
with cls._lock:
return cls._RESOURCES.copy()
3 changes: 2 additions & 1 deletion src/viam/resource/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re
import sys
from typing import TYPE_CHECKING, Callable, ClassVar, Mapping
from typing import TYPE_CHECKING, Callable, ClassVar, Mapping, Sequence

if sys.version_info >= (3, 10):
from typing import TypeAlias
Expand Down Expand Up @@ -197,3 +197,4 @@ def resource_name_from_string(string: str) -> ResourceName:


ResourceCreator: TypeAlias = Callable[[ComponentConfig, Mapping[ResourceName, "ResourceBase"]], "ResourceBase"]
Validator: TypeAlias = Callable[[ComponentConfig], Sequence[str]]
5 changes: 3 additions & 2 deletions tests/mocks/module/gizmo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from viam.resource.registry import Registry, ResourceRegistration
from viam.components.motor import * # noqa: F403 Need to import motor so the component registers itself
from viam.resource.registry import Registry, ResourceRegistration, ResourceCreatorRegistration

from .api import Gizmo, GizmoClient, GizmoService
from .my_gizmo import MyGizmo

Registry.register_subtype(ResourceRegistration(Gizmo, GizmoService, lambda name, channel: GizmoClient(name, channel)))

Registry.register_resource_creator(Gizmo.SUBTYPE, MyGizmo.MODEL, MyGizmo.new)
Registry.register_resource_creator(Gizmo.SUBTYPE, MyGizmo.MODEL, ResourceCreatorRegistration(MyGizmo.new, MyGizmo.validate_config))
14 changes: 13 additions & 1 deletion tests/mocks/module/gizmo/my_gizmo.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import ClassVar, Mapping, Sequence
from typing import ClassVar, List, Mapping, Sequence

from typing_extensions import Self

Expand All @@ -22,6 +22,18 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
gizmo.my_arg = config.attributes.fields["arg1"].string_value
return gizmo

@classmethod
def validate_config(cls, config: ComponentConfig) -> List[str]:
if "invalid" in config.attributes.fields:
raise Exception(f"'invalid' attribute not allowed for model {cls.SUBTYPE}:{cls.MODEL}")
arg1 = config.attributes.fields["arg1"].string_value
if arg1 == "":
raise Exception("A arg1 attribute is required for Gizmo component.")
motor = [config.attributes.fields["motor"].string_value]
if motor == [""]:
raise Exception("A motor is required for Gizmo component.")
return motor

async def do_one(self, arg1: str, **kwargs) -> bool:
return arg1 == self.my_arg

Expand Down
4 changes: 2 additions & 2 deletions tests/mocks/module/summation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from viam.resource.registry import Registry, ResourceRegistration
from viam.resource.registry import Registry, ResourceCreatorRegistration, ResourceRegistration

from .api import SummationClient, SummationRPCService, SummationService
from .my_summation import MySummationService

Registry.register_subtype(ResourceRegistration(SummationService, SummationRPCService, lambda name, channel: SummationClient(name, channel)))

Registry.register_resource_creator(SummationService.SUBTYPE, MySummationService.MODEL, MySummationService.new)
Registry.register_resource_creator(SummationService.SUBTYPE, MySummationService.MODEL, ResourceCreatorRegistration(MySummationService.new))
Loading