From 6cdecba1844875d42ddc322c328892ed83704a93 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Mon, 2 Feb 2026 15:10:29 -0500 Subject: [PATCH 01/12] init --- tests/test_examples.py | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/test_examples.py diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 000000000..c43a95798 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,159 @@ +"""Tests to verify that example code compiles and imports correctly.""" + +import importlib +import importlib.util +import os +import py_compile +from pathlib import Path + +import pytest + +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" + + +def verify_python_file_syntax(filepath: Path) -> None: + try: + py_compile.compile(str(filepath), doraise=True) + finally: + # Clean up .pyc file if created + pyc_file = str(filepath) + 'c' + if os.path.exists(pyc_file): + os.remove(pyc_file) + # Also check __pycache__ directory + pycache_dir = filepath.parent / "__pycache__" + if pycache_dir.exists(): + for pyc in pycache_dir.glob(f"{filepath.stem}.*.pyc"): + pyc.unlink() + + +def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Path | None = None) -> None: + import sys + + # Temporarily add directory to sys.path if needed for relative imports + path_added = False + if add_to_path and str(add_to_path) not in sys.path: + sys.path.insert(0, str(add_to_path)) + path_added = True + + try: + # For files with add_to_path (complex_module, simple_module), use proper import + # This lets Python handle all the package setup automatically + if add_to_path: + # Calculate module name relative to add_to_path + rel_to_add_path = filepath.relative_to(add_to_path) + + # Convert file path to module name: + # - api.py -> api + # - gizmo/api.py -> gizmo.api + # - gizmo/__init__.py -> gizmo (because __init__.py IS the package) + path_to_convert = rel_to_add_path.parent if filepath.name == "__init__.py" else rel_to_add_path.with_suffix("") + module_name = str(path_to_convert).replace("/", ".").replace("\\", ".") + + # Use importlib.import_module - Python handles all package setup + try: + importlib.import_module(module_name) + except ModuleNotFoundError as e: + # Skip if module depends on optional packages (PIL, numpy, etc.) + pytest.skip(f"Skipping {filepath.name} - missing optional dependency: {e.name}") + else: + # For files without add_to_path, load directly (no relative imports expected) + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {filepath}") + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except ModuleNotFoundError as e: + # Skip if module depends on optional packages (PIL, numpy, etc.) + pytest.skip(f"Skipping {filepath.name} - missing optional dependency: {e.name}") + finally: + # Clean up sys.path + if path_added: + sys.path.remove(str(add_to_path)) + + +def get_all_python_files() -> list[Path]: + # Get all Python files in the examples directory + if not EXAMPLES_DIR.exists(): + return [] + + python_files = [] + + def walk_directory(dir_path: Path) -> None: + # Recursively walk directory, skipping __pycache__ directories. + try: + for item in dir_path.iterdir(): + # Skip __pycache__ directories entirely + if item.is_dir() and item.name == "__pycache__": + continue + + if item.is_dir(): + walk_directory(item) + elif item.is_file() and item.suffix == ".py": + python_files.append(item) + except PermissionError: + # Skip directories we can't access + pass + + walk_directory(EXAMPLES_DIR) + return sorted(python_files) + + +# Get all Python files at module load time for parametrization +_ALL_PYTHON_FILES = get_all_python_files() + + +class TestExamplesSyntax: + """Verify all example Python files have valid syntax.""" + + @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) + def test_python_file_syntax(self, py_file: Path): + """Verify a Python file has valid syntax.""" + verify_python_file_syntax(py_file) + + +def get_add_to_path_for_file(file_path: Path) -> Path | None: + """ + Determine if a file needs add_to_path for imports. + + Returns the directory to add to sys.path, or None if not needed. + Files in certain example directories need their top-level directory added to path. + """ + # List of example directories that use relative imports and need their directory in sys.path + EXAMPLES_WITH_PACKAGES = {"complex_module", "simple_module", "server"} + + # Find the top-level example directory (first directory under examples/) + # e.g., examples/complex_module/src/gizmo/api.py -> examples/complex_module + # e.g., examples/server/v1/server.py -> examples/server + current = file_path.parent + while current != EXAMPLES_DIR and current.parent != EXAMPLES_DIR: + current = current.parent + + # Return the top-level example directory if it's in our list + if current != EXAMPLES_DIR and current.parent == EXAMPLES_DIR and current.name in EXAMPLES_WITH_PACKAGES: + return current + + return None + + +# Get all Python files with their add_to_path settings at module load time +_PYTHON_FILES_WITH_PATH = [(py_file, get_add_to_path_for_file(py_file)) for py_file in _ALL_PYTHON_FILES] +_PYTHON_FILES_IDS = [str(py_file.relative_to(EXAMPLES_DIR)) for py_file in _ALL_PYTHON_FILES] + + +class TestExamplesImports: + """Verify all example Python files can be imported (checks import statements).""" + + @pytest.mark.parametrize( + "py_file,add_to_path", + _PYTHON_FILES_WITH_PATH, + ids=_PYTHON_FILES_IDS + ) + def test_python_file_imports(self, py_file: Path, add_to_path: Path | None): + """Verify a Python file can be imported.""" + # Generate a module name (only used when add_to_path is None) + # When add_to_path is set, verify_python_file_imports recalculates it properly + relative_path = py_file.relative_to(EXAMPLES_DIR) + module_name = str(relative_path).replace("/", "_").replace("\\", "_").replace(".py", "") + + verify_python_file_imports(py_file, module_name, add_to_path=add_to_path) From 2fb076b3ebe2afb523c18eb254d7c0bf2d1a1e77 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Mon, 2 Feb 2026 15:13:25 -0500 Subject: [PATCH 02/12] don't skip modules --- tests/test_examples.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index c43a95798..5cf6d6510 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -50,22 +50,14 @@ def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Pa module_name = str(path_to_convert).replace("/", ".").replace("\\", ".") # Use importlib.import_module - Python handles all package setup - try: - importlib.import_module(module_name) - except ModuleNotFoundError as e: - # Skip if module depends on optional packages (PIL, numpy, etc.) - pytest.skip(f"Skipping {filepath.name} - missing optional dependency: {e.name}") + importlib.import_module(module_name) else: # For files without add_to_path, load directly (no relative imports expected) spec = importlib.util.spec_from_file_location(module_name, filepath) if spec is None or spec.loader is None: raise ImportError(f"Could not load spec for {filepath}") module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except ModuleNotFoundError as e: - # Skip if module depends on optional packages (PIL, numpy, etc.) - pytest.skip(f"Skipping {filepath.name} - missing optional dependency: {e.name}") + spec.loader.exec_module(module) finally: # Clean up sys.path if path_added: From 99ab62540f2639aeb4a3cdae93539d0c594f4137 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 12:10:49 -0500 Subject: [PATCH 03/12] Update test_examples.py --- tests/test_examples.py | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 5cf6d6510..fa338a205 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -2,31 +2,16 @@ import importlib import importlib.util -import os import py_compile from pathlib import Path +from typing import Optional import pytest EXAMPLES_DIR = Path(__file__).parent.parent / "examples" -def verify_python_file_syntax(filepath: Path) -> None: - try: - py_compile.compile(str(filepath), doraise=True) - finally: - # Clean up .pyc file if created - pyc_file = str(filepath) + 'c' - if os.path.exists(pyc_file): - os.remove(pyc_file) - # Also check __pycache__ directory - pycache_dir = filepath.parent / "__pycache__" - if pycache_dir.exists(): - for pyc in pycache_dir.glob(f"{filepath.stem}.*.pyc"): - pyc.unlink() - - -def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Path | None = None) -> None: +def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Optional[Path] = None) -> None: import sys # Temporarily add directory to sys.path if needed for relative imports @@ -75,10 +60,6 @@ def walk_directory(dir_path: Path) -> None: # Recursively walk directory, skipping __pycache__ directories. try: for item in dir_path.iterdir(): - # Skip __pycache__ directories entirely - if item.is_dir() and item.name == "__pycache__": - continue - if item.is_dir(): walk_directory(item) elif item.is_file() and item.suffix == ".py": @@ -101,10 +82,10 @@ class TestExamplesSyntax: @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) def test_python_file_syntax(self, py_file: Path): """Verify a Python file has valid syntax.""" - verify_python_file_syntax(py_file) + py_compile.compile(str(py_file), doraise=True) -def get_add_to_path_for_file(file_path: Path) -> Path | None: +def get_add_to_path_for_file(file_path: Path) -> Optional[Path]: """ Determine if a file needs add_to_path for imports. @@ -136,12 +117,8 @@ def get_add_to_path_for_file(file_path: Path) -> Path | None: class TestExamplesImports: """Verify all example Python files can be imported (checks import statements).""" - @pytest.mark.parametrize( - "py_file,add_to_path", - _PYTHON_FILES_WITH_PATH, - ids=_PYTHON_FILES_IDS - ) - def test_python_file_imports(self, py_file: Path, add_to_path: Path | None): + @pytest.mark.parametrize("py_file,add_to_path", _PYTHON_FILES_WITH_PATH, ids=_PYTHON_FILES_IDS) + def test_python_file_imports(self, py_file: Path, add_to_path: Optional[Path]): """Verify a Python file can be imported.""" # Generate a module name (only used when add_to_path is None) # When add_to_path is set, verify_python_file_imports recalculates it properly From b28daa73a927d66d06089d003fdd0915b34b12e5 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 14:45:46 -0500 Subject: [PATCH 04/12] Update test_examples.py --- tests/test_examples.py | 82 +++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 53 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index fa338a205..c36069fe8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -3,6 +3,7 @@ import importlib import importlib.util import py_compile +import sys from pathlib import Path from typing import Optional @@ -11,33 +12,26 @@ EXAMPLES_DIR = Path(__file__).parent.parent / "examples" -def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Optional[Path] = None) -> None: - import sys - +# Python doesn't build all imports automatically, so recursively check all imports, and then the imported module's import, etc... +def verify_python_file_imports(filepath: Path, module_name: str, package_root: Optional[Path] = None) -> None: # Temporarily add directory to sys.path if needed for relative imports path_added = False - if add_to_path and str(add_to_path) not in sys.path: - sys.path.insert(0, str(add_to_path)) + if package_root and str(package_root) not in sys.path: + sys.path.insert(0, str(package_root)) path_added = True try: - # For files with add_to_path (complex_module, simple_module), use proper import - # This lets Python handle all the package setup automatically - if add_to_path: - # Calculate module name relative to add_to_path - rel_to_add_path = filepath.relative_to(add_to_path) - - # Convert file path to module name: - # - api.py -> api - # - gizmo/api.py -> gizmo.api - # - gizmo/__init__.py -> gizmo (because __init__.py IS the package) - path_to_convert = rel_to_add_path.parent if filepath.name == "__init__.py" else rel_to_add_path.with_suffix("") + if package_root: + # Calculate module name relative to package_root + rel_to_package_root = filepath.relative_to(package_root) + + # Recalculate module name relative to package_root for module import + path_to_convert = rel_to_package_root.parent if filepath.name == "__init__.py" else rel_to_package_root.with_suffix("") module_name = str(path_to_convert).replace("/", ".").replace("\\", ".") - # Use importlib.import_module - Python handles all package setup importlib.import_module(module_name) else: - # For files without add_to_path, load directly (no relative imports expected) + # For files without relative imports, load directly (no relative imports expected) spec = importlib.util.spec_from_file_location(module_name, filepath) if spec is None or spec.loader is None: raise ImportError(f"Could not load spec for {filepath}") @@ -46,18 +40,13 @@ def verify_python_file_imports(filepath: Path, module_name: str, add_to_path: Op finally: # Clean up sys.path if path_added: - sys.path.remove(str(add_to_path)) + sys.path.remove(str(package_root)) def get_all_python_files() -> list[Path]: - # Get all Python files in the examples directory - if not EXAMPLES_DIR.exists(): - return [] - python_files = [] def walk_directory(dir_path: Path) -> None: - # Recursively walk directory, skipping __pycache__ directories. try: for item in dir_path.iterdir(): if item.is_dir(): @@ -77,52 +66,39 @@ def walk_directory(dir_path: Path) -> None: class TestExamplesSyntax: - """Verify all example Python files have valid syntax.""" - @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) def test_python_file_syntax(self, py_file: Path): - """Verify a Python file has valid syntax.""" py_compile.compile(str(py_file), doraise=True) -def get_add_to_path_for_file(file_path: Path) -> Optional[Path]: - """ - Determine if a file needs add_to_path for imports. - - Returns the directory to add to sys.path, or None if not needed. - Files in certain example directories need their top-level directory added to path. - """ +def get_package_root_for_file(file_path: Path) -> Optional[Path]: # List of example directories that use relative imports and need their directory in sys.path EXAMPLES_WITH_PACKAGES = {"complex_module", "simple_module", "server"} - # Find the top-level example directory (first directory under examples/) - # e.g., examples/complex_module/src/gizmo/api.py -> examples/complex_module - # e.g., examples/server/v1/server.py -> examples/server + # Find which example the file belongs to (like complex_module vs server) current = file_path.parent - while current != EXAMPLES_DIR and current.parent != EXAMPLES_DIR: + while current.parent != EXAMPLES_DIR: current = current.parent - # Return the top-level example directory if it's in our list - if current != EXAMPLES_DIR and current.parent == EXAMPLES_DIR and current.name in EXAMPLES_WITH_PACKAGES: + # Return the directory if it needs sys.path support for relative imports + if current.name in EXAMPLES_WITH_PACKAGES: return current return None -# Get all Python files with their add_to_path settings at module load time -_PYTHON_FILES_WITH_PATH = [(py_file, get_add_to_path_for_file(py_file)) for py_file in _ALL_PYTHON_FILES] -_PYTHON_FILES_IDS = [str(py_file.relative_to(EXAMPLES_DIR)) for py_file in _ALL_PYTHON_FILES] - - class TestExamplesImports: - """Verify all example Python files can be imported (checks import statements).""" - - @pytest.mark.parametrize("py_file,add_to_path", _PYTHON_FILES_WITH_PATH, ids=_PYTHON_FILES_IDS) - def test_python_file_imports(self, py_file: Path, add_to_path: Optional[Path]): - """Verify a Python file can be imported.""" - # Generate a module name (only used when add_to_path is None) - # When add_to_path is set, verify_python_file_imports recalculates it properly + # list of all python file's path from examples dir + whether it needs to be added to sys.path for proper imports + _PYTHON_FILES_WITH_PATH = [(py_file, get_package_root_for_file(py_file)) for py_file in _ALL_PYTHON_FILES] + # create file ids for each test so you can see which file is successfully building/not building + _PYTHON_FILES_IDS = [str(py_file.relative_to(EXAMPLES_DIR)) for py_file in _ALL_PYTHON_FILES] + + # Verify that all example Python files can be imported (checks import statements). + @pytest.mark.parametrize("py_file,package_root", _PYTHON_FILES_WITH_PATH, ids=_PYTHON_FILES_IDS) + def test_python_file_imports(self, py_file: Path, package_root: Optional[Path]): relative_path = py_file.relative_to(EXAMPLES_DIR) + # create unique identifier for each module module_name = str(relative_path).replace("/", "_").replace("\\", "_").replace(".py", "") - verify_python_file_imports(py_file, module_name, add_to_path=add_to_path) + # package_root will be the directory to be added to path for files with relative imports or None if none + verify_python_file_imports(py_file, module_name, package_root=package_root) From 0153f0bc0acfa0469d6281cb3f3078492f1a2cbb Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 14:46:50 -0500 Subject: [PATCH 05/12] format --- tests/test_examples.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index c36069fe8..68e00ca1b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,3 @@ -"""Tests to verify that example code compiles and imports correctly.""" - import importlib import importlib.util import py_compile @@ -65,12 +63,6 @@ def walk_directory(dir_path: Path) -> None: _ALL_PYTHON_FILES = get_all_python_files() -class TestExamplesSyntax: - @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) - def test_python_file_syntax(self, py_file: Path): - py_compile.compile(str(py_file), doraise=True) - - def get_package_root_for_file(file_path: Path) -> Optional[Path]: # List of example directories that use relative imports and need their directory in sys.path EXAMPLES_WITH_PACKAGES = {"complex_module", "simple_module", "server"} @@ -87,6 +79,12 @@ def get_package_root_for_file(file_path: Path) -> Optional[Path]: return None +class TestExamplesSyntax: + @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) + def test_python_file_syntax(self, py_file: Path): + py_compile.compile(str(py_file), doraise=True) + + class TestExamplesImports: # list of all python file's path from examples dir + whether it needs to be added to sys.path for proper imports _PYTHON_FILES_WITH_PATH = [(py_file, get_package_root_for_file(py_file)) for py_file in _ALL_PYTHON_FILES] From f488499e5f2cbfade342e3bc056970eb14fe9535 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 14:50:07 -0500 Subject: [PATCH 06/12] Update test_examples.py --- tests/test_examples.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 68e00ca1b..68dcf52e3 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -3,7 +3,7 @@ import py_compile import sys from pathlib import Path -from typing import Optional +from typing import List, Optional import pytest @@ -41,7 +41,7 @@ def verify_python_file_imports(filepath: Path, module_name: str, package_root: O sys.path.remove(str(package_root)) -def get_all_python_files() -> list[Path]: +def get_all_python_files() -> List[Path]: python_files = [] def walk_directory(dir_path: Path) -> None: From 0b40bdd0f4a065c72ecbf8e2a2c14377fd3fd1b0 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 14:58:15 -0500 Subject: [PATCH 07/12] Update test_examples.py --- tests/test_examples.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 68dcf52e3..052687d50 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -7,6 +7,8 @@ import pytest +from viam.errors import DuplicateResourceError + EXAMPLES_DIR = Path(__file__).parent.parent / "examples" @@ -27,14 +29,21 @@ def verify_python_file_imports(filepath: Path, module_name: str, package_root: O path_to_convert = rel_to_package_root.parent if filepath.name == "__init__.py" else rel_to_package_root.with_suffix("") module_name = str(path_to_convert).replace("/", ".").replace("\\", ".") - importlib.import_module(module_name) + try: + importlib.import_module(module_name) + except DuplicateResourceError: + # building all examples files leads to registering resources multiple times, which is expected + pass else: # For files without relative imports, load directly (no relative imports expected) spec = importlib.util.spec_from_file_location(module_name, filepath) if spec is None or spec.loader is None: raise ImportError(f"Could not load spec for {filepath}") module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + except DuplicateResourceError: + pass finally: # Clean up sys.path if path_added: From a570eba35af1e798608749e94e40084f72a9aad3 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 16:08:49 -0500 Subject: [PATCH 08/12] update to make 3.8 compatible --- examples/complex_module/src/base/my_base.py | 2 +- examples/optionaldepsmodule/module.py | 4 ++-- src/viam/errors.py | 2 +- src/viam/robot/service.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/complex_module/src/base/my_base.py b/examples/complex_module/src/base/my_base.py index cc896f3d7..8a7a527ae 100644 --- a/examples/complex_module/src/base/my_base.py +++ b/examples/complex_module/src/base/my_base.py @@ -139,7 +139,7 @@ async def is_moving(self) -> bool: return await self.left.is_moving() or await self.right.is_moving() # Not implemented - async def get_properties(self, *, timeout: Optional[float] | None = None, **kwargs) -> Base.Properties: + async def get_properties(self, *, timeout: Optional[float] = None, **kwargs) -> Base.Properties: raise NotImplementedError() # Not implemented diff --git a/examples/optionaldepsmodule/module.py b/examples/optionaldepsmodule/module.py index 1d74895a9..22292bf67 100644 --- a/examples/optionaldepsmodule/module.py +++ b/examples/optionaldepsmodule/module.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Mapping, Sequence, cast +from typing import ClassVar, Mapping, Sequence, Tuple, cast from typing_extensions import Self @@ -30,7 +30,7 @@ def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, Resour # Validate validates the config and returns a required dependency on # `required_motor` and an optional dependency on `optional_motor`. @classmethod - def validate_config(cls, config: ComponentConfig) -> tuple[Sequence[str], Sequence[str]]: + def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]: attributes_dict = struct_to_dict(config.attributes) cfg_required_motor: str = cast(str, attributes_dict.get("required_motor")) diff --git a/src/viam/errors.py b/src/viam/errors.py index b9a6b0e7d..05bc359ae 100644 --- a/src/viam/errors.py +++ b/src/viam/errors.py @@ -22,7 +22,7 @@ class InsecureConnectionError(ViamError): def __init__(self, address: str, authenticated: bool = False) -> None: self.address = address self.authenticated = authenticated - self.message = f"Requested address {self.address} is insecure" + f'{" and will not send credentials" if self.authenticated else ""}' + self.message = f"Requested address {self.address} is insecure" + f"{' and will not send credentials' if self.authenticated else ''}" super().__init__(self.message) diff --git a/src/viam/robot/service.py b/src/viam/robot/service.py index 151fe7e9f..11ac4cc37 100644 --- a/src/viam/robot/service.py +++ b/src/viam/robot/service.py @@ -65,5 +65,5 @@ async def StopAll(self, stream: Stream[StopAllRequest, StopAllResponse]) -> None errors.append(component.name) if errors: - raise ViamGRPCError(f'Failed to stop components named {", ".join(errors)}') + raise ViamGRPCError(f"Failed to stop components named {', '.join(errors)}") await stream.send_message(StopAllResponse()) From dd00b58463cdd95fc2214cd9b1b5eabb69e47e9c Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 16:17:30 -0500 Subject: [PATCH 09/12] undo format --- src/viam/errors.py | 2 +- src/viam/robot/service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viam/errors.py b/src/viam/errors.py index 05bc359ae..b9a6b0e7d 100644 --- a/src/viam/errors.py +++ b/src/viam/errors.py @@ -22,7 +22,7 @@ class InsecureConnectionError(ViamError): def __init__(self, address: str, authenticated: bool = False) -> None: self.address = address self.authenticated = authenticated - self.message = f"Requested address {self.address} is insecure" + f"{' and will not send credentials' if self.authenticated else ''}" + self.message = f"Requested address {self.address} is insecure" + f'{" and will not send credentials" if self.authenticated else ""}' super().__init__(self.message) diff --git a/src/viam/robot/service.py b/src/viam/robot/service.py index 11ac4cc37..151fe7e9f 100644 --- a/src/viam/robot/service.py +++ b/src/viam/robot/service.py @@ -65,5 +65,5 @@ async def StopAll(self, stream: Stream[StopAllRequest, StopAllResponse]) -> None errors.append(component.name) if errors: - raise ViamGRPCError(f"Failed to stop components named {', '.join(errors)}") + raise ViamGRPCError(f'Failed to stop components named {", ".join(errors)}') await stream.send_message(StopAllResponse()) From 4f78cf5ddb881863980def5f5c25a4c088b76264 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Tue, 3 Feb 2026 16:37:45 -0500 Subject: [PATCH 10/12] update test --- tests/test_module.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/test_module.py b/tests/test_module.py index e17669971..8c0799cd1 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -188,21 +188,20 @@ async def test_ready(self, module: Module): req = ReadyRequest(parent_address=p_addr) resp = await module.ready(req) assert module._parent_address == p_addr - assert len(resp.handlermap.handlers) == 2 - - handler = resp.handlermap.handlers[0] - rn = Gizmo.get_resource_name("") - assert handler.subtype == ResourceRPCSubtype(subtype=rn, proto_service="acme.component.gizmo.v1.GizmoService") - assert len(handler.models) == 1 - model = handler.models[0] - assert model == "acme:demo:mygizmo" - - handler = resp.handlermap.handlers[1] - rn = SummationService.get_resource_name("") - assert handler.subtype == ResourceRPCSubtype(subtype=rn, proto_service="acme.service.summation.v1.SummationService") - assert len(handler.models) == 1 - model = handler.models[0] - assert model == "acme:demo:mysum" + + # Find Gizmo handler + gizmo_rn = Gizmo.get_resource_name("") + gizmo_handler = next((h for h in resp.handlermap.handlers if h.subtype == ResourceRPCSubtype(subtype=gizmo_rn, proto_service="acme.component.gizmo.v1.GizmoService")), None) + assert gizmo_handler is not None + assert len(gizmo_handler.models) == 1 + assert gizmo_handler.models[0] == "acme:demo:mygizmo" + + # Find Summation handler + summation_rn = SummationService.get_resource_name("") + summation_handler = next((h for h in resp.handlermap.handlers if h.subtype == ResourceRPCSubtype(subtype=summation_rn, proto_service="acme.service.summation.v1.SummationService")), None) + assert summation_handler is not None + assert len(summation_handler.models) == 1 + assert summation_handler.models[0] == "acme:demo:mysum" def test_add_model_from_registry(self): mod = Module("fake") From 7a6a1b66d8f9989ea472e484dc8f4c7105023aa3 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Thu, 12 Feb 2026 15:01:19 -0500 Subject: [PATCH 11/12] Update test_examples.py --- tests/test_examples.py | 204 ++++++++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 86 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 052687d50..2acbdf421 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,111 +1,143 @@ +import asyncio import importlib import importlib.util -import py_compile import sys from pathlib import Path -from typing import List, Optional +from unittest import mock +from unittest.mock import AsyncMock import pytest -from viam.errors import DuplicateResourceError +from viam.module import Module +from viam.resource.registry import Registry +from viam.resource.types import API, Model EXAMPLES_DIR = Path(__file__).parent.parent / "examples" -# Python doesn't build all imports automatically, so recursively check all imports, and then the imported module's import, etc... -def verify_python_file_imports(filepath: Path, module_name: str, package_root: Optional[Path] = None) -> None: - # Temporarily add directory to sys.path if needed for relative imports - path_added = False - if package_root and str(package_root) not in sys.path: - sys.path.insert(0, str(package_root)) - path_added = True - - try: - if package_root: - # Calculate module name relative to package_root - rel_to_package_root = filepath.relative_to(package_root) - - # Recalculate module name relative to package_root for module import - path_to_convert = rel_to_package_root.parent if filepath.name == "__init__.py" else rel_to_package_root.with_suffix("") - module_name = str(path_to_convert).replace("/", ".").replace("\\", ".") - - try: - importlib.import_module(module_name) - except DuplicateResourceError: - # building all examples files leads to registering resources multiple times, which is expected - pass - else: - # For files without relative imports, load directly (no relative imports expected) - spec = importlib.util.spec_from_file_location(module_name, filepath) - if spec is None or spec.loader is None: - raise ImportError(f"Could not load spec for {filepath}") - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except DuplicateResourceError: - pass - finally: - # Clean up sys.path - if path_added: - sys.path.remove(str(package_root)) - - -def get_all_python_files() -> List[Path]: - python_files = [] - - def walk_directory(dir_path: Path) -> None: - try: - for item in dir_path.iterdir(): - if item.is_dir(): - walk_directory(item) - elif item.is_file() and item.suffix == ".py": - python_files.append(item) - except PermissionError: - # Skip directories we can't access - pass +def discover_examples(): + """Discover example directories with entry points. + + Searches for common entry point patterns. Examples that don't match are skipped. + """ + examples = [] + for d in sorted(EXAMPLES_DIR.iterdir()): + if not d.is_dir(): + continue + for candidate in [d / "src" / "main.py", d / "main.py", d / "module.py", d / "v1" / "server.py"]: + if candidate.exists(): + examples.append((d, candidate)) + break + return examples - walk_directory(EXAMPLES_DIR) - return sorted(python_files) +EXAMPLES = discover_examples() +EXAMPLE_IDS = [ex[0].name for ex in EXAMPLES] -# Get all Python files at module load time for parametrization -_ALL_PYTHON_FILES = get_all_python_files() +@pytest.fixture(autouse=True) +def isolate_registry(monkeypatch): + """Start each test with empty registries. -def get_package_root_for_file(file_path: Path) -> Optional[Path]: - # List of example directories that use relative imports and need their directory in sys.path - EXAMPLES_WITH_PACKAGES = {"complex_module", "simple_module", "server"} + This prevents DuplicateResourceError from test mocks (tests/mocks/module/) that register + the same APIs as the examples (e.g., acme:component:gizmo). With empty registries, the + example's registrations succeed and the full import chain completes. + Monkeypatch restores the originals after the test. + """ + monkeypatch.setattr(Registry, "_APIS", {}) + monkeypatch.setattr(Registry, "_RESOURCES", {}) - # Find which example the file belongs to (like complex_module vs server) - current = file_path.parent - while current.parent != EXAMPLES_DIR: - current = current.parent - # Return the directory if it needs sys.path support for relative imports - if current.name in EXAMPLES_WITH_PACKAGES: - return current +@pytest.fixture(autouse=True) +def clean_example_modules(): + """Remove modules added during the test from sys.modules. - return None + Tracks modules before/after so we don't need to hardcode package names like 'src' or 'v1'. + """ + before = set(sys.modules.keys()) + yield + for key in set(sys.modules.keys()) - before: + del sys.modules[key] -class TestExamplesSyntax: - @pytest.mark.parametrize("py_file", _ALL_PYTHON_FILES, ids=lambda p: str(p.relative_to(EXAMPLES_DIR))) - def test_python_file_syntax(self, py_file: Path): - py_compile.compile(str(py_file), doraise=True) +def import_file(filepath: Path, module_name: str): + """Import a standalone Python file by path.""" + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {filepath}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module -class TestExamplesImports: - # list of all python file's path from examples dir + whether it needs to be added to sys.path for proper imports - _PYTHON_FILES_WITH_PATH = [(py_file, get_package_root_for_file(py_file)) for py_file in _ALL_PYTHON_FILES] - # create file ids for each test so you can see which file is successfully building/not building - _PYTHON_FILES_IDS = [str(py_file.relative_to(EXAMPLES_DIR)) for py_file in _ALL_PYTHON_FILES] +class TestBuildModules: + @pytest.mark.parametrize("example_dir,entry_point", EXAMPLES, ids=EXAMPLE_IDS) + async def test_build_module(self, example_dir: Path, entry_point: Path): + # Entry points in subdirectories (src/, v1/) need package-style import + has_package = entry_point.parent != example_dir - # Verify that all example Python files can be imported (checks import statements). - @pytest.mark.parametrize("py_file,package_root", _PYTHON_FILES_WITH_PATH, ids=_PYTHON_FILES_IDS) - def test_python_file_imports(self, py_file: Path, package_root: Optional[Path]): - relative_path = py_file.relative_to(EXAMPLES_DIR) - # create unique identifier for each module - module_name = str(relative_path).replace("/", "_").replace("\\", "_").replace(".py", "") + if has_package: + sys.path.insert(0, str(example_dir)) - # package_root will be the directory to be added to path for files with relative imports or None if none - verify_python_file_imports(py_file, module_name, package_root=package_root) + try: + # Snapshot registry to detect new registrations + resources_before = set(Registry._RESOURCES.keys()) + + # Import the entry point + mod = self._import_entry_point(example_dir, entry_point, has_package) + + # If it has an async main() and uses viam Module, mock Module.from_args/start + # and call it. This exercises the example's real registration + module setup. + # We check for "Module" in vars(mod) to skip non-module examples (e.g., echo) + # whose main() would start a real gRPC server. + built_via_main = False + if ( + mod is not None + and "Module" in vars(mod) + and hasattr(mod, "main") + and asyncio.iscoroutinefunction(mod.main) + ): + built_via_main = await self._run_mocked_main(mod.main) + + # If main() didn't run (no main, or non-module example), + # build a Module from whatever new models got registered during import. + if not built_via_main: + new_resources = set(Registry._RESOURCES.keys()) - resources_before + if new_resources: + await self._build_module_from_registry(new_resources) + + # Import client.py if it exists + for client_candidate in [example_dir / "client.py", example_dir / "v1" / "client.py"]: + if client_candidate.exists(): + import_file(client_candidate, f"{example_dir.name}_client") + break + finally: + if has_package and str(example_dir) in sys.path: + sys.path.remove(str(example_dir)) + + def _import_entry_point(self, example_dir, entry_point, has_package): + """Import the entry point, using package import for subdirectory-based examples.""" + if has_package: + module_path = ".".join(entry_point.relative_to(example_dir).with_suffix("").parts) + return importlib.import_module(module_path) + else: + return import_file(entry_point, f"{example_dir.name}_entry") + + async def _run_mocked_main(self, main_fn) -> bool: + """Mock Module.from_args/start and call main(). Returns True if successful.""" + fake_module = Module("fake_address") + with mock.patch("viam.module.module.Module.from_args", return_value=fake_module): + with mock.patch.object(Module, "start", new_callable=AsyncMock): + await main_fn() + await fake_module.stop() + return True + + async def _build_module_from_registry(self, resource_keys): + """Build a Module using newly registered resource models.""" + module = Module("fake_address") + for key in resource_keys: + api_str, model_str = key.split("/") + api = API.from_string(api_str) + model = Model.from_string(model_str) + module.add_model_from_registry(api, model) + await module.stop() From 0b177ed454fc90e0bb88cdab8d62e0b53a3a4735 Mon Sep 17 00:00:00 2001 From: Allison Chiang Date: Thu, 12 Feb 2026 15:05:57 -0500 Subject: [PATCH 12/12] Revert "update test" This reverts commit 4f78cf5ddb881863980def5f5c25a4c088b76264. --- tests/test_module.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/test_module.py b/tests/test_module.py index 8c0799cd1..e17669971 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -188,20 +188,21 @@ async def test_ready(self, module: Module): req = ReadyRequest(parent_address=p_addr) resp = await module.ready(req) assert module._parent_address == p_addr - - # Find Gizmo handler - gizmo_rn = Gizmo.get_resource_name("") - gizmo_handler = next((h for h in resp.handlermap.handlers if h.subtype == ResourceRPCSubtype(subtype=gizmo_rn, proto_service="acme.component.gizmo.v1.GizmoService")), None) - assert gizmo_handler is not None - assert len(gizmo_handler.models) == 1 - assert gizmo_handler.models[0] == "acme:demo:mygizmo" - - # Find Summation handler - summation_rn = SummationService.get_resource_name("") - summation_handler = next((h for h in resp.handlermap.handlers if h.subtype == ResourceRPCSubtype(subtype=summation_rn, proto_service="acme.service.summation.v1.SummationService")), None) - assert summation_handler is not None - assert len(summation_handler.models) == 1 - assert summation_handler.models[0] == "acme:demo:mysum" + assert len(resp.handlermap.handlers) == 2 + + handler = resp.handlermap.handlers[0] + rn = Gizmo.get_resource_name("") + assert handler.subtype == ResourceRPCSubtype(subtype=rn, proto_service="acme.component.gizmo.v1.GizmoService") + assert len(handler.models) == 1 + model = handler.models[0] + assert model == "acme:demo:mygizmo" + + handler = resp.handlermap.handlers[1] + rn = SummationService.get_resource_name("") + assert handler.subtype == ResourceRPCSubtype(subtype=rn, proto_service="acme.service.summation.v1.SummationService") + assert len(handler.models) == 1 + model = handler.models[0] + assert model == "acme:demo:mysum" def test_add_model_from_registry(self): mod = Module("fake")