Skip to content

Commit 90ac8d7

Browse files
authored
Support Optional Dependencies (#918)
1 parent baa5a44 commit 90ac8d7

File tree

11 files changed

+204
-24
lines changed

11 files changed

+204
-24
lines changed

examples/complex_module/src/base/my_base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, ClassVar, Mapping, Dict, List, Optional, cast, Sequence
1+
from typing import Any, ClassVar, Mapping, Dict, List, Optional, cast, Sequence, Tuple
22

33
from typing_extensions import Self
44

@@ -39,7 +39,7 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
3939

4040
# Validates JSON Configuration
4141
@classmethod
42-
def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
42+
def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]:
4343
attributes_dict = struct_to_dict(config.attributes)
4444
left_name = attributes_dict.get("left", "")
4545
assert isinstance(left_name, str)
@@ -50,7 +50,7 @@ def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
5050
assert isinstance(right_name, str)
5151
if right_name == "":
5252
raise Exception("A right attribute is required for a MyBase component.")
53-
return [left_name, right_name]
53+
return [left_name, right_name], []
5454

5555
# Handles attribute reconfiguration
5656
def reconfigure(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):

examples/complex_module/src/gizmo/my_gizmo.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import ClassVar, Mapping, Sequence
1+
from typing import ClassVar, Mapping, Sequence, Tuple
22

33
from typing_extensions import Self
44

@@ -33,10 +33,10 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
3333
return gizmo
3434

3535
@classmethod
36-
def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
36+
def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]:
3737
# Custom validation can be done by specifiying a validate function like this one. Validate functions
38-
# can raise errors that will be returned to the parent through gRPC. Validate functions can
39-
# also return a sequence of strings representing the implicit dependencies of the resource.
38+
# can raise errors that will be returned to the parent through gRPC. Validate functions can also
39+
# return two sequences of strings, representing the implicit required & optional dependencies of the resource.
4040
if "invalid" in config.attributes.fields:
4141
raise Exception(f"'invalid' attribute not allowed for model {cls.API}:{cls.MODEL}")
4242
arg1 = config.attributes.fields["arg1"].string_value
@@ -45,7 +45,7 @@ def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
4545
motor = [config.attributes.fields["motor"].string_value]
4646
if motor == [""]:
4747
raise Exception("A motor is required for Gizmo component.")
48-
return motor
48+
return motor, []
4949

5050
async def do_one(self, arg1: str, **kwargs) -> bool:
5151
return arg1 == self.my_arg
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"modules": [
3+
{
4+
"name": "optional_deps_module",
5+
"executable_path": "/home/viam-python-sdk/examples/optionaldepsmodule/run.sh"
6+
}
7+
],
8+
"components": [
9+
{
10+
"type": "generic",
11+
"name": "f",
12+
"model": "acme:demo:foo",
13+
"attributes": {
14+
"required_motor": "m",
15+
"optional_motor": "m2"
16+
}
17+
},
18+
{
19+
"type": "motor",
20+
"name": "m",
21+
"model": "fake"
22+
}
23+
]
24+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from typing import ClassVar, Mapping, Sequence, cast
2+
3+
from typing_extensions import Self
4+
5+
from viam.components.generic import Generic
6+
from viam.components.motor import Motor
7+
from viam.module.types import Reconfigurable
8+
from viam.proto.app.robot import ComponentConfig
9+
from viam.resource.base import ResourceBase
10+
from viam.proto.common import ResourceName
11+
from viam.resource.registry import Registry, ResourceCreatorRegistration
12+
from viam.resource.types import Model, ModelFamily
13+
from viam.utils import struct_to_dict
14+
from viam.module.module import Module
15+
import asyncio
16+
17+
18+
class Foo(Generic, Reconfigurable):
19+
MODEL: ClassVar[Model] = Model(ModelFamily("acme", "demo"), "foo")
20+
21+
def __init__(self, name: str):
22+
super().__init__(name)
23+
24+
@classmethod
25+
def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
26+
foo = cls(config.name)
27+
foo.reconfigure(config, dependencies)
28+
return foo
29+
30+
# Validate validates the config and returns a required dependency on
31+
# `required_motor` and an optional dependency on `optional_motor`.
32+
@classmethod
33+
def validate_config(cls, config: ComponentConfig) -> tuple[Sequence[str], Sequence[str]]:
34+
attributes_dict = struct_to_dict(config.attributes)
35+
36+
cfg_required_motor: str = cast(str, attributes_dict.get("required_motor"))
37+
cfg_optional_motor: str = cast(str, attributes_dict.get("optional_motor"))
38+
39+
required_deps = []
40+
optional_deps = []
41+
42+
if cfg_required_motor is None or cfg_required_motor == "":
43+
raise Exception(f'expected "required_motor" attribute for foo {config}')
44+
45+
required_deps.append(cfg_required_motor)
46+
47+
if cfg_optional_motor is not None and cfg_optional_motor != "":
48+
optional_deps.append(cfg_optional_motor)
49+
50+
return required_deps, optional_deps
51+
52+
# Reconfigure with latest dependencies
53+
def reconfigure(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]):
54+
attributes_dict = struct_to_dict(config.attributes)
55+
56+
cfg_required_motor: str = cast(str, attributes_dict.get("required_motor"))
57+
cfg_optional_motor: str = cast(str, attributes_dict.get("optional_motor"))
58+
59+
required_motor = Motor.get_resource_name(cfg_required_motor)
60+
if required_motor not in dependencies:
61+
raise Exception(f"could not get required motor {cfg_required_motor} from dependencies")
62+
else:
63+
self.required_motor = required_motor
64+
65+
optional_motor = Motor.get_resource_name(cfg_optional_motor)
66+
if optional_motor not in dependencies:
67+
print(f'could not get optional motor {cfg_optional_motor} from dependencies; continuing')
68+
else:
69+
self.optional_motor = optional_motor
70+
71+
72+
async def main():
73+
"""This function creates and starts a new module, after adding all desired resource models.
74+
Resource creators must be registered to the resource registry before the module adds the resource model.
75+
"""
76+
77+
Registry.register_resource_creator(Generic.API, Foo.MODEL, ResourceCreatorRegistration(Foo.new, Foo.validate_config))
78+
79+
module = Module.from_args()
80+
module.add_model_from_registry(Generic.API, Foo.MODEL)
81+
await module.start()
82+
83+
84+
if __name__ == "__main__":
85+
asyncio.run(main())

examples/optionaldepsmodule/run.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/sh
2+
cd `dirname $0`
3+
4+
# Create a virtual environment to run our code
5+
VENV_NAME="venv"
6+
PYTHON="$VENV_NAME/bin/python"
7+
ENV_ERROR="This module requires Python >=3.8, pip, and virtualenv to be installed."
8+
9+
if ! python3 -m venv $VENV_NAME >/dev/null 2>&1; then
10+
echo "Failed to create virtualenv."
11+
if command -v apt-get >/dev/null; then
12+
echo "Detected Debian/Ubuntu, attempting to install python3-venv automatically."
13+
SUDO="sudo"
14+
if ! command -v $SUDO >/dev/null; then
15+
SUDO=""
16+
fi
17+
if ! apt info python3-venv >/dev/null 2>&1; then
18+
echo "Package info not found, trying apt update"
19+
$SUDO apt -qq update >/dev/null
20+
fi
21+
$SUDO apt install -qqy python3-venv >/dev/null 2>&1
22+
if ! python3 -m venv $VENV_NAME >/dev/null 2>&1; then
23+
echo $ENV_ERROR >&2
24+
exit 1
25+
fi
26+
else
27+
echo $ENV_ERROR >&2
28+
exit 1
29+
fi
30+
fi
31+
32+
# remove -U if viam-sdk should not be upgraded whenever possible
33+
# -qq suppresses extraneous output from pip
34+
echo "Virtualenv found/created. Installing/upgrading Python packages..."
35+
if ! $PYTHON -m pip install -r requirements.txt -Uqq; then
36+
exit 1
37+
fi
38+
39+
# Be sure to use `exec` so that termination signals reach the python process,
40+
# or handle forwarding termination signals manually
41+
echo "Starting module..."
42+
exec $PYTHON module.py $@

examples/simple_module/src/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from typing import Any, ClassVar, Dict, Mapping, Optional, Sequence
2+
from typing import Any, ClassVar, Dict, Mapping, Optional, Sequence, Tuple
33

44
from typing_extensions import Self
55

@@ -30,14 +30,14 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
3030
return sensor
3131

3232
@classmethod
33-
def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
33+
def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]:
3434
if "multiplier" in config.attributes.fields:
3535
if not config.attributes.fields["multiplier"].HasField("number_value"):
3636
raise Exception("Multiplier must be a float.")
3737
multiplier = config.attributes.fields["multiplier"].number_value
3838
if multiplier == 0:
3939
raise Exception("Multiplier cannot be 0.")
40-
return []
40+
return [], []
4141

4242
async def get_readings(self, extra: Optional[Dict[str, Any]] = None, **kwargs) -> Mapping[str, SensorReading]:
4343
return {"signal": 1 * self.multiplier}

src/viam/module/module.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging as pylogging
44
import os
55
import sys
6+
from collections.abc import Iterable
67
from inspect import iscoroutinefunction
78
from threading import Lock
89
from typing import List, Mapping, Optional, Sequence, Tuple
@@ -275,7 +276,35 @@ async def validate_config(self, request: ValidateConfigRequest) -> ValidateConfi
275276
model = Model.from_string(config.model)
276277
validator = Registry.lookup_validator(api, model)
277278
try:
278-
dependencies = validator(config)
279-
return ValidateConfigResponse(dependencies=dependencies)
279+
# backwards compatibility. Support both ([], []) or [] with deprecation warning.
280+
# If user's validate returns [str], it will be treated as required dependencies only.
281+
# Incorect formats, e.g. int, will raise ValidationError.
282+
_validator_return_test = validator(config)
283+
if not (
284+
isinstance(_validator_return_test, tuple)
285+
and len(_validator_return_test) == 2
286+
):
287+
msg = f"Your validate function {validator.__name__} did not return \
288+
type tuple[Sequence[str], Sequence[str]]. Got {_validator_return_test}."
289+
self.logger.warning(msg)
290+
if (
291+
isinstance(_validator_return_test, Iterable)
292+
and not isinstance(_validator_return_test, str)
293+
) and all(
294+
isinstance(e, str) for e in _validator_return_test # type: ignore
295+
):
296+
self.logger.warning(
297+
f"Detected deprecated validate function signature. \
298+
Treating all dependencies {_validator_return_test} as required dependencies. \
299+
Please update to new signature Tuple[Sequence[str], Sequence[str]] soon."
300+
)
301+
return ValidateConfigResponse(dependencies=_validator_return_test)
302+
else:
303+
raise ValidationError(msg)
304+
305+
dependencies, optional_dependencies = _validator_return_test
306+
return ValidateConfigResponse(
307+
dependencies=dependencies, optional_dependencies=optional_dependencies
308+
)
280309
except Exception as e:
281310
raise ValidationError(f"{type(Exception)}: {e}").grpc_error

src/viam/resource/easy_resource.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22
import re
33
from abc import ABCMeta
4-
from typing import Callable, ClassVar, Mapping, Sequence, Union
4+
from typing import Callable, ClassVar, Mapping, Sequence, Tuple, Union
55

66
from viam.proto.app.robot import ComponentConfig
77
from viam.proto.common import ResourceName
@@ -122,17 +122,17 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour
122122
return self
123123

124124
@classmethod
125-
def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
125+
def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]:
126126
"""This method allows you to validate the configuration object received from the machine,
127127
as well as to return any implicit dependencies based on that `config`.
128128
129129
Args:
130130
config (ComponentConfig): The configuration for this resource
131131
132132
Returns:
133-
Sequence[str]: A list of implicit dependencies
133+
Tuple[Sequence[str], Sequence[str]]: One list of required implicit dependencies and one of optional deps.
134134
"""
135-
return []
135+
return [], []
136136

137137
@classmethod
138138
def register(cls):

src/viam/resource/registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class ResourceCreatorRegistration:
2727
"""A function that can create a resource given a mapping of dependencies (``ResourceName`` to ``ResourceBase``
2828
"""
2929

30-
validator: "Validator" = lambda x: []
30+
validator: "Validator" = lambda x: ([], [])
3131
"""A function that can validate a resource and return implicit dependencies.
3232
3333
If called without a validator function, default to a function returning an empty Sequence
@@ -170,7 +170,7 @@ def lookup_validator(cls, api: "API", model: "Model") -> "Validator":
170170
try:
171171
return cls._RESOURCES[f"{api}/{model}"].validator
172172
except AttributeError:
173-
return lambda x: []
173+
return lambda x: ([], [])
174174
except KeyError:
175175
raise ResourceNotFoundError(api.resource_type, api.resource_subtype)
176176

src/viam/resource/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22
import sys
3-
from typing import TYPE_CHECKING, Callable, ClassVar, Mapping, Optional, Protocol, Sequence, runtime_checkable
3+
from typing import TYPE_CHECKING, Callable, ClassVar, Mapping, Optional, Protocol, Sequence, Tuple, runtime_checkable
44

55
if sys.version_info >= (3, 10):
66
from typing import TypeAlias
@@ -203,7 +203,7 @@ def resource_name_from_string(string: str) -> ResourceName:
203203

204204

205205
ResourceCreator: TypeAlias = Callable[[ComponentConfig, Mapping[ResourceName, "ResourceBase"]], "ResourceBase"]
206-
Validator: TypeAlias = Callable[[ComponentConfig], Sequence[str]]
206+
Validator: TypeAlias = Callable[[ComponentConfig], Tuple[Sequence[str], Sequence[str]]]
207207

208208

209209
@runtime_checkable

0 commit comments

Comments
 (0)