From 9d635ef904f8ebaec0161828e2f19c949db44d2c Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter Date: Fri, 21 Aug 2026 10:41:38 +0200 Subject: [PATCH] add stub-driven lazy imports using `lazy_loader` Replace DeepLabCut's hand-maintained lazy export maps (_API_EXPORTS_MAP, _OPTIONAL_EXPORTS, and the __all__ export lists) with Scientific Python's `lazy_loader.attach_stub`. `deeplabcut/__init__.pyi` is now the single declarative source of truth for the public API: static tools read it for discoverability and signatures, while lazy_loader parses it at runtime to install `__getattr__`, `__dir__`, and `__all__`, importing each implementation module only when its attribute is first accessed. This keeps the flat API intact and avoids eagerly importing heavy or optional modules such as `pose_estimation_pytorch`, `gui`, and `pose_tracking_pytorch` during `import deeplabcut`. - add `lazy_loader` as a runtime dependency, - add `__init__.pyi` and `py.typed` - add tests for the new loading behavior. --- .pre-commit-config.yaml | 1 + deeplabcut/__init__.py | 288 ++++++---------------------------- deeplabcut/__init__.pyi | 155 ++++++++++++++++++ deeplabcut/py.typed | 0 pyproject.toml | 4 +- tests/test_top_level_api.py | 185 ++++++++++++++++++++++ tests/test_type_stub.py | 64 ++++++++ tests/typing/top_level_api.py | 31 ++++ uv.lock | 2 + 9 files changed, 490 insertions(+), 240 deletions(-) create mode 100644 deeplabcut/__init__.pyi create mode 100644 deeplabcut/py.typed create mode 100644 tests/test_top_level_api.py create mode 100644 tests/test_type_stub.py create mode 100644 tests/typing/top_level_api.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 21f085c90f..4d1563cc2f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,7 @@ repos: stages: [pre-commit, manual] - id: name-tests-test args: [--pytest-test-first] + exclude: ^tests/typing/ stages: [pre-commit, manual] - id: check-json stages: [pre-commit, manual] diff --git a/deeplabcut/__init__.py b/deeplabcut/__init__.py index 9d104c2415..ceff6362af 100644 --- a/deeplabcut/__init__.py +++ b/deeplabcut/__init__.py @@ -13,272 +13,82 @@ import logging import os -from importlib import import_module -from typing import Any +import warnings + +import lazy_loader as lazy + +from deeplabcut.core.deprecation import DLCDeprecationWarning + +from .version import VERSION, __version__ logger = logging.getLogger(__name__) # DEBUG="", "0", "false", "no" -> False DEBUG = os.environ.get("DEBUG", "").strip().lower() not in {"", "0", "false", "no"} -from .version import VERSION, __version__ - if DEBUG: logger.debug("Loading DLC %s", VERSION) # DeepLabCut deprecation warnings are shown only once per message instance. -import warnings - -from deeplabcut.core.deprecation import DLCDeprecationWarning - warnings.filterwarnings("once", category=DLCDeprecationWarning) # ----------------------------------------------------------------------------- -# Always-available public API +# Stub-driven lazy loading +# ----------------------------------------------------------------------------- +# ``deeplabcut/__init__.pyi`` is the single declarative source of truth for the +# top-level public API. ``lazy_loader.attach_stub`` reads it at runtime to +# install ``__getattr__``, ``__dir__``, and ``__all__``, so each implementation +# module is imported only when its top-level attribute is first accessed. # ----------------------------------------------------------------------------- -from .core.engine import Engine -from .create_project import ( - add_new_videos, - create_new_project, - create_new_project_3d, - create_pretrained_human_project, - load_demo_data, -) -from .generate_training_dataset import ( - adddatasetstovideolistandviceversa, - check_labels, - comparevideolistsanddatafolders, - create_multianimaltraining_dataset, - create_training_dataset, - create_training_dataset_from_existing_split, - create_training_model_comparison, - dropannotationfileentriesduetodeletedimages, - dropduplicatesinannotatinfiles, - dropimagesduetolackofannotation, - dropunlabeledframes, - extract_frames, - mergeandsplit, -) -from .pose_estimation_3d import ( - calibrate_cameras, - check_undistortion, - create_labeled_video_3d, - triangulate, -) -from .utils import ( - analyze_videos_converth5_to_csv, - analyze_videos_converth5_to_nwb, - auxfun_videos, - auxiliaryfunctions, - convert2_maDLC, - convertcsv2h5, - create_labeled_video, - create_video_with_all_detections, - plot_trajectories, -) -from .utils.auxfun_videos import ( - CropVideo, - DownSampleVideo, - ShortenVideo, - check_video_integrity, - collect_video_paths, -) +_lazy_getattr, __dir__, __all__ = lazy.attach_stub(__name__, __file__) # ----------------------------------------------------------------------------- -# Optional / lazy public API +# Optional-dependency diagnostics # ----------------------------------------------------------------------------- -# These names are part of the public API, but importing them may require -# optional GUI or torch dependencies, so we lazy load them. -# -# Example: -# import deeplabcut as dlc -# dlc.launch_dlc() # imports GUI code lazily -# dlc.transformer_reID(...) # imports torch-dependent code lazily +# A plain ``attach_stub`` raises ``ModuleNotFoundError`` when a GUI or PyTorch +# tracking module is unavailable. Translate only those into actionable +# ``ImportError`` messages and leave unrelated import failures untouched. # ----------------------------------------------------------------------------- -_OPTIONAL_EXPORTS: dict[str, tuple[str, str]] = { - # GUI - "launch_dlc": (".gui.launch_script", "launch_dlc"), - "label_frames": (".gui.tabs.label_frames", "label_frames"), - "refine_labels": (".gui.tabs.label_frames", "refine_labels"), - "refine_tracklets": (".gui.tracklet_toolbox", "refine_tracklets"), - "SkeletonBuilder": (".gui.widgets", "SkeletonBuilder"), - # Optional torch feature - "transformer_reID": (".pose_tracking_pytorch", "transformer_reID"), -} - -# API exports are lazily loaded from the pose_estimation API facade. -_API_EXPORTS_MAP: dict[str, tuple[str, str]] = { - "analyze_images": (".api.pose_estimation", "analyze_images"), - "analyze_time_lapse_frames": (".api.pose_estimation", "analyze_time_lapse_frames"), - "analyze_videos": (".api.pose_estimation", "analyze_videos"), - "convert_detections2tracklets": (".api.pose_estimation", "convert_detections2tracklets"), - "create_pretrained_project": (".api.create_project", "create_pretrained_project"), - "create_tracking_dataset": (".api.pose_estimation", "create_tracking_dataset"), - "evaluate_network": (".api.pose_estimation", "evaluate_network"), - "export_model": (".api.pose_estimation", "export_model"), - "extract_maps": (".api.pose_estimation", "extract_maps"), - "extract_save_all_maps": (".api.pose_estimation", "extract_save_all_maps"), - "return_evaluate_network_data": (".api.pose_estimation", "return_evaluate_network_data"), - "return_train_network_path": (".api.pose_estimation", "return_train_network_path"), - "train_network": (".api.pose_estimation", "train_network"), - "visualize_locrefs": (".api.pose_estimation", "visualize_locrefs"), - "visualize_paf": (".api.pose_estimation", "visualize_paf"), - "visualize_scoremaps": (".api.pose_estimation", "visualize_scoremaps"), - "analyzeskeleton": (".api.post_processing", "analyzeskeleton"), - "filterpredictions": (".api.post_processing", "filterpredictions"), - "extract_outlier_frames": (".api.refine_training", "extract_outlier_frames"), - "find_outliers_in_raw_data": (".api.refine_training", "find_outliers_in_raw_data"), - "merge_datasets": (".api.refine_training", "merge_datasets"), - "stitch_tracklets": (".api.refine_training", "stitch_tracklets"), - "video_inference_superanimal": (".api.modelzoo_inference", "video_inference_superanimal"), -} - - -def __getattr__(name: str) -> Any: - """Lazily load optional public exports and API exports.""" - # Check API exports first (always available, lightweight import) - if name in _API_EXPORTS_MAP: - module_name, attr_name = _API_EXPORTS_MAP[name] - module = import_module(module_name, package=__name__) - value = getattr(module, attr_name) - globals()[name] = value - return value - - if name not in _OPTIONAL_EXPORTS: - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - module_name, attr_name = _OPTIONAL_EXPORTS[name] - - try: - module = import_module(module_name, package=__name__) - value = getattr(module, attr_name) - except (ModuleNotFoundError, ImportError) as exc: - if name in { - "launch_dlc", - "label_frames", - "refine_labels", - "refine_tracklets", - "SkeletonBuilder", - }: - raise AttributeError( - f"{name!r} is unavailable because DeepLabCut was loaded without GUI dependencies." - ) from exc - - if name == "transformer_reID": - raise AttributeError( - f"{name!r} is unavailable because the PyTorch-based tracking dependencies are not installed." - ) from exc - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc - - # Cache the resolved object so future access is fast - globals()[name] = value - return value - - -def __dir__() -> list[str]: - """Improve IDE / autocomplete discoverability.""" - return sorted(set(globals()) | set(__all__)) - +_GUI_EXPORTS = frozenset( + { + "launch_dlc", + "label_frames", + "refine_labels", + "refine_tracklets", + "SkeletonBuilder", + } +) -# ----------------------------------------------------------------------------- -# Public API -# ----------------------------------------------------------------------------- +_TORCH_EXPORTS = frozenset({"transformer_reID"}) -_VERSION_EXPORTS = [ - "__version__", - "VERSION", - "DEBUG", -] +_GUI_DEPENDENCY_MODULES = frozenset({"PySide6", "napari", "qdarkstyle"}) +_TORCH_DEPENDENCY_MODULES = frozenset({"torch", "torchvision"}) -_CORE_EXPORTS = [ - "Engine", -] -_PROJECT_EXPORTS = [ - "add_new_videos", - "create_new_project", - "create_new_project_3d", - "create_pretrained_human_project", - "load_demo_data", -] +def _is_missing_gui_dependency(exc: ModuleNotFoundError) -> bool: + """Return True if ``exc`` is caused by a missing GUI dependency.""" + name = getattr(exc, "name", None) + return isinstance(name, str) and name.split(".")[0] in _GUI_DEPENDENCY_MODULES -_DATASET_EXPORTS = [ - "adddatasetstovideolistandviceversa", - "check_labels", - "comparevideolistsanddatafolders", - "create_multianimaltraining_dataset", - "create_training_dataset", - "create_training_dataset_from_existing_split", - "create_training_model_comparison", - "dropannotationfileentriesduetodeletedimages", - "dropduplicatesinannotatinfiles", - "dropimagesduetolackofannotation", - "dropunlabeledframes", - "extract_frames", - "mergeandsplit", -] -_API_EXPORTS = [ - "analyze_images", - "analyze_time_lapse_frames", - "analyze_videos", - "convert_detections2tracklets", - "create_tracking_dataset", - "evaluate_network", - "export_model", - "extract_maps", - "extract_save_all_maps", - "return_evaluate_network_data", - "return_train_network_path", - "train_network", - "visualize_locrefs", - "visualize_paf", - "visualize_scoremaps", - "analyzeskeleton", - "create_pretrained_project", - "filterpredictions", - "extract_outlier_frames", - "find_outliers_in_raw_data", - "merge_datasets", - "stitch_tracklets", - "video_inference_superanimal", -] +def _is_missing_torch_dependency(exc: ModuleNotFoundError) -> bool: + """Return True if ``exc`` is caused by a missing PyTorch dependency.""" + name = getattr(exc, "name", None) + return isinstance(name, str) and name.split(".")[0] in _TORCH_DEPENDENCY_MODULES -_UTIL_EXPORTS = [ - "analyze_videos_converth5_to_csv", - "analyze_videos_converth5_to_nwb", - "auxfun_videos", - "auxiliaryfunctions", - "convert2_maDLC", - "convertcsv2h5", - "create_labeled_video", - "create_video_with_all_detections", - "plot_trajectories", - "CropVideo", - "DownSampleVideo", - "ShortenVideo", - "check_video_integrity", -] -_THREE_D_EXPORTS = [ - "calibrate_cameras", - "check_undistortion", - "create_labeled_video_3d", - "triangulate", -] +def __getattr__(name: str): + try: + return _lazy_getattr(name) + except ModuleNotFoundError as exc: + if name in _GUI_EXPORTS and _is_missing_gui_dependency(exc): + raise ImportError( + f"{name!r} requires the DeepLabCut GUI dependencies. Install the supported GUI extra." + ) from exc -_OPTIONAL_API_EXPORTS = list(_OPTIONAL_EXPORTS) + if name in _TORCH_EXPORTS and _is_missing_torch_dependency(exc): + raise ImportError(f"{name!r} requires the PyTorch tracking dependencies.") from exc -__all__ = ( - _VERSION_EXPORTS - + _CORE_EXPORTS - + _PROJECT_EXPORTS - + _DATASET_EXPORTS - + _API_EXPORTS - + _UTIL_EXPORTS - + _THREE_D_EXPORTS - + _OPTIONAL_API_EXPORTS -) + raise diff --git a/deeplabcut/__init__.pyi b/deeplabcut/__init__.pyi new file mode 100644 index 0000000000..eac4f31144 --- /dev/null +++ b/deeplabcut/__init__.pyi @@ -0,0 +1,155 @@ +# Static and runtime export contract for deeplabcut's top-level API. +# lazy_loader.attach_stub reads this file at runtime. +# Keep public exports here rather than in a parallel Python mapping. + +from .api.create_project import ( + create_pretrained_project as create_pretrained_project, +) +from .api.modelzoo_inference import ( + video_inference_superanimal as video_inference_superanimal, +) +from .api.pose_estimation import ( + analyze_images as analyze_images, +) +from .api.pose_estimation import ( + analyze_time_lapse_frames as analyze_time_lapse_frames, +) +from .api.pose_estimation import ( + analyze_videos as analyze_videos, +) +from .api.pose_estimation import ( + convert_detections2tracklets as convert_detections2tracklets, +) +from .api.pose_estimation import ( + create_tracking_dataset as create_tracking_dataset, +) +from .api.pose_estimation import ( + evaluate_network as evaluate_network, +) +from .api.pose_estimation import ( + export_model as export_model, +) +from .api.pose_estimation import ( + extract_maps as extract_maps, +) +from .api.pose_estimation import ( + extract_save_all_maps as extract_save_all_maps, +) +from .api.pose_estimation import ( + return_evaluate_network_data as return_evaluate_network_data, +) +from .api.pose_estimation import ( + return_train_network_path as return_train_network_path, +) +from .api.pose_estimation import ( + train_network as train_network, +) +from .api.pose_estimation import ( + visualize_locrefs as visualize_locrefs, +) +from .api.pose_estimation import ( + visualize_paf as visualize_paf, +) +from .api.pose_estimation import ( + visualize_scoremaps as visualize_scoremaps, +) +from .api.post_processing import analyzeskeleton as analyzeskeleton +from .api.post_processing import filterpredictions as filterpredictions +from .api.refine_training import ( + extract_outlier_frames as extract_outlier_frames, +) +from .api.refine_training import ( + find_outliers_in_raw_data as find_outliers_in_raw_data, +) +from .api.refine_training import merge_datasets as merge_datasets +from .api.refine_training import stitch_tracklets as stitch_tracklets +from .core.engine import Engine as Engine +from .create_project import add_new_videos as add_new_videos +from .create_project import create_new_project as create_new_project +from .create_project import create_new_project_3d as create_new_project_3d +from .create_project import ( + create_pretrained_human_project as create_pretrained_human_project, +) +from .create_project import load_demo_data as load_demo_data +from .generate_training_dataset.frame_extraction import ( + extract_frames as extract_frames, +) +from .generate_training_dataset.multiple_individuals_trainingsetmanipulation import ( + create_multianimaltraining_dataset as create_multianimaltraining_dataset, +) +from .generate_training_dataset.trainingsetmanipulation import ( + adddatasetstovideolistandviceversa as adddatasetstovideolistandviceversa, +) +from .generate_training_dataset.trainingsetmanipulation import ( + check_labels as check_labels, +) +from .generate_training_dataset.trainingsetmanipulation import ( + comparevideolistsanddatafolders as comparevideolistsanddatafolders, +) +from .generate_training_dataset.trainingsetmanipulation import ( + create_training_dataset as create_training_dataset, +) +from .generate_training_dataset.trainingsetmanipulation import ( + create_training_dataset_from_existing_split as create_training_dataset_from_existing_split, +) +from .generate_training_dataset.trainingsetmanipulation import ( + create_training_model_comparison as create_training_model_comparison, +) +from .generate_training_dataset.trainingsetmanipulation import ( + dropannotationfileentriesduetodeletedimages as dropannotationfileentriesduetodeletedimages, +) +from .generate_training_dataset.trainingsetmanipulation import ( + dropduplicatesinannotatinfiles as dropduplicatesinannotatinfiles, +) +from .generate_training_dataset.trainingsetmanipulation import ( + dropimagesduetolackofannotation as dropimagesduetolackofannotation, +) +from .generate_training_dataset.trainingsetmanipulation import ( + dropunlabeledframes as dropunlabeledframes, +) +from .generate_training_dataset.trainingsetmanipulation import ( + mergeandsplit as mergeandsplit, +) +from .gui.launch_script import launch_dlc as launch_dlc +from .gui.tabs.label_frames import label_frames as label_frames +from .gui.tabs.label_frames import refine_labels as refine_labels +from .gui.tracklet_toolbox import refine_tracklets as refine_tracklets +from .gui.widgets import SkeletonBuilder as SkeletonBuilder +from .pose_estimation_3d.camera_calibration import ( + calibrate_cameras as calibrate_cameras, +) +from .pose_estimation_3d.camera_calibration import ( + check_undistortion as check_undistortion, +) +from .pose_estimation_3d.plotting3D import ( + create_labeled_video_3d as create_labeled_video_3d, +) +from .pose_estimation_3d.triangulation import triangulate as triangulate +from .pose_tracking_pytorch import transformer_reID as transformer_reID +from .utils import auxfun_videos as auxfun_videos +from .utils import auxiliaryfunctions as auxiliaryfunctions +from .utils.auxfun_multianimal import convert2_maDLC as convert2_maDLC +from .utils.auxfun_videos import CropVideo as CropVideo +from .utils.auxfun_videos import DownSampleVideo as DownSampleVideo +from .utils.auxfun_videos import ShortenVideo as ShortenVideo +from .utils.auxfun_videos import check_video_integrity as check_video_integrity +from .utils.auxfun_videos import collect_video_paths as collect_video_paths +from .utils.conversioncode import ( + analyze_videos_converth5_to_csv as analyze_videos_converth5_to_csv, +) +from .utils.conversioncode import ( + analyze_videos_converth5_to_nwb as analyze_videos_converth5_to_nwb, +) +from .utils.conversioncode import convertcsv2h5 as convertcsv2h5 +from .utils.make_labeled_video import ( + create_labeled_video as create_labeled_video, +) +from .utils.make_labeled_video import ( + create_video_with_all_detections as create_video_with_all_detections, +) +from .utils.plotting import plot_trajectories as plot_trajectories +from .version import VERSION as VERSION +from .version import __version__ as __version__ + +# DEBUG has no canonical declaration in another module, so declare it directly. +DEBUG: bool diff --git a/deeplabcut/py.typed b/deeplabcut/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pyproject.toml b/pyproject.toml index 2f5a2e6174..cbee0dc36f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "huggingface-hub>=0.23", "imageio-ffmpeg", "imgaug>=0.4", + "lazy-loader>=0.4", "matplotlib>=3.3,<3.9,!=3.7,!=3.7.1", "networkx>=2.6", "numba>=0.54", @@ -169,7 +170,7 @@ gui-dev = [ [tool.setuptools] include-package-data = false [tool.setuptools.package-data] -"*" = [ "*.yaml", "*.yml", "*.json", "*.qss", "*.png", "*.md", "*.sh" ] +"*" = [ "*.yaml", "*.yml", "*.json", "*.qss", "*.png", "*.md", "*.sh", "*.pyi", "py.typed" ] [tool.setuptools.packages.find] include = [ "deeplabcut*" ] exclude = [ "tests*", "docs*", "examples*" ] @@ -215,6 +216,7 @@ ignore = [ "E741", "B007" ] "deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py" = [ "F403" ] "deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py" = [ "F403" ] "*.ipynb" = [ "E402" ] +"tests/typing/top_level_api.py" = [ "F821" ] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/tests/test_top_level_api.py b/tests/test_top_level_api.py new file mode 100644 index 0000000000..4f9c1f325d --- /dev/null +++ b/tests/test_top_level_api.py @@ -0,0 +1,185 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests for the stub-driven lazy loading of ``deeplabcut``'s top-level API. + +``deeplabcut/__init__.pyi`` is the single declarative source of truth for the +public API. ``lazy_loader.attach_stub`` reads it at runtime to install +``__getattr__``, ``__dir__``, and ``__all__``, importing each implementation +module only when its attribute is first accessed. These tests assert that the +stub drives both static discovery and lazy runtime resolution. +""" + +from __future__ import annotations + +import ast +import importlib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +import deeplabcut + +_STUB_PATH = Path(deeplabcut.__file__).with_name("__init__.pyi") + + +def _stub_public_names() -> set[str]: + """Return every public name declared in ``deeplabcut/__init__.pyi``.""" + tree = ast.parse(_STUB_PATH.read_text(encoding="utf-8")) + names: set[str] = set() + for node in tree.body: + if isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name != "*": + names.add(alias.asname or alias.name) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + names.add(node.target.id) + return names + + +def _module_available(name: str) -> bool: + try: + importlib.import_module(name) + except ImportError: + return False + return True + + +def test_expected_top_level_api() -> None: + assert callable(deeplabcut.analyze_images) + assert callable(deeplabcut.analyze_videos) + assert callable(deeplabcut.train_network) + + +def test_flat_import_remains_supported() -> None: + from deeplabcut import analyze_images + + assert callable(analyze_images) + + +def test_all_exports_appear_in_dir() -> None: + assert set(deeplabcut.__all__) <= set(dir(deeplabcut)) + + +def test_lazy_export_returns_stable_object() -> None: + from deeplabcut.api.pose_estimation import analyze_images as canonical + + first = deeplabcut.analyze_images + second = deeplabcut.analyze_images + + assert first is second + assert first is canonical + + +def test_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="No deeplabcut attribute"): + _ = deeplabcut.this_name_does_not_exist + + +def test_stub_declares_every_runtime_export() -> None: + missing = set(deeplabcut.__all__) - _stub_public_names() + assert not missing, f"Runtime exports missing from stub: {sorted(missing)}" + + +def test_stub_declares_no_unexpected_exports() -> None: + # ``DEBUG`` is declared directly (``DEBUG: bool``) but is eagerly defined in + # ``__init__.py``, so ``lazy_loader`` does not add it to ``__all__``. + extra = _stub_public_names() - set(deeplabcut.__all__) + assert extra <= {"DEBUG"}, f"Stub declares unexpected names: {sorted(extra)}" + + +def test_stub_and_py_typed_ship_with_package() -> None: + pkg_dir = Path(deeplabcut.__file__).parent + assert (pkg_dir / "__init__.pyi").is_file() + assert (pkg_dir / "py.typed").is_file() + + +def test_pose_estimation_is_loaded_lazily() -> None: + code = ( + "import sys\n" + "import deeplabcut\n" + "assert 'deeplabcut.api.pose_estimation' not in sys.modules\n" + "_ = deeplabcut.analyze_images\n" + "assert 'deeplabcut.api.pose_estimation' in sys.modules\n" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_gui_module_is_not_loaded_eagerly() -> None: + code = "import sys\nimport deeplabcut\nassert 'deeplabcut.gui' not in sys.modules\n" + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_torch_tracking_module_is_not_loaded_eagerly() -> None: + code = "import sys\nimport deeplabcut\nassert 'deeplabcut.pose_tracking_pytorch' not in sys.modules\n" + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_import_deeplabcut_is_lightweight() -> None: + code = "\n".join( + [ + "import sys", + "import deeplabcut", + "heavy = [", + " 'deeplabcut.api',", + " 'deeplabcut.create_project',", + " 'deeplabcut.generate_training_dataset',", + " 'deeplabcut.utils',", + " 'deeplabcut.pose_estimation_3d',", + " 'deeplabcut.pose_estimation_pytorch',", + " 'deeplabcut.gui',", + " 'deeplabcut.pose_tracking_pytorch',", + " 'torch',", + " 'tensorflow',", + "]", + "for mod in heavy:", + " assert mod not in sys.modules, f'{mod} imported eagerly'", + ] + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_gui_missing_dependency_is_translated(monkeypatch) -> None: + def fake_lazy_getattr(name): + raise ModuleNotFoundError("No module named 'PySide6'", name="PySide6") + + monkeypatch.setattr(deeplabcut, "_lazy_getattr", fake_lazy_getattr) + with pytest.raises(ImportError, match="GUI dependencies"): + _ = deeplabcut.launch_dlc + + +def test_torch_missing_dependency_is_translated(monkeypatch) -> None: + def fake_lazy_getattr(name): + raise ModuleNotFoundError("No module named 'torch'", name="torch") + + monkeypatch.setattr(deeplabcut, "_lazy_getattr", fake_lazy_getattr) + with pytest.raises(ImportError, match="PyTorch tracking"): + _ = deeplabcut.transformer_reID + + +def test_unrelated_import_error_is_not_masked(monkeypatch) -> None: + def fake_lazy_getattr(name): + raise ModuleNotFoundError("No module named 'some_unrelated_module'", name="some_unrelated_module") + + monkeypatch.setattr(deeplabcut, "_lazy_getattr", fake_lazy_getattr) + with pytest.raises(ModuleNotFoundError): + _ = deeplabcut.launch_dlc + + +@pytest.mark.skipif( + not (_module_available("torch") and _module_available("PySide6")), + reason="Full dependency set (torch + GUI) required for eager-import validation", +) +def test_eager_import_mode_resolves_all_exports() -> None: + env = {**os.environ, "EAGER_IMPORT": "1"} + subprocess.run([sys.executable, "-c", "import deeplabcut"], check=True, env=env) diff --git a/tests/test_type_stub.py b/tests/test_type_stub.py new file mode 100644 index 0000000000..72f12cb4d1 --- /dev/null +++ b/tests/test_type_stub.py @@ -0,0 +1,64 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Runs a focused type-checker smoke test over the top-level public API. + +The fixture lives in ``tests/typing/top_level_api.py`` and is analyzed with +Pyright (or basedpyright). The check is skipped when neither is installed so it +never breaks a base CI job; run it where the checker is available to confirm +``deeplabcut/__init__.pyi`` resolves every lazy export to a real signature. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_FIXTURE = Path(__file__).parent / "typing" / "top_level_api.py" +_TYPE_CHECKERS = ("basedpyright", "pyright") + + +def _find_type_checker() -> str | None: + for cmd in _TYPE_CHECKERS: + if shutil.which(cmd): + return cmd + return None + + +@pytest.mark.skipif(_find_type_checker() is None, reason="No pyright/basedpyright available") +def test_top_level_api_resolves_statically() -> None: + checker = _find_type_checker() + proc = subprocess.run( + [checker, str(_FIXTURE), "--outputjson"], + capture_output=True, + text=True, + ) + data = json.loads(proc.stdout) + + errors = [diagnostic for diagnostic in data.get("generalDiagnostics", []) if diagnostic.get("severity") == "error"] + assert not errors, "\n".join( + f"{e.get('file')}:{e.get('range', {}).get('start', {}).get('line', '?')}: {e.get('message')}" for e in errors + ) + + # ``reveal_type`` results must not degrade to ``Any`` or ``Unknown``, which + # would mean the stub failed to expose the name statically. + reveal_messages = [ + diagnostic.get("message", "") + for diagnostic in data.get("generalDiagnostics", []) + if 'is "' in diagnostic.get("message", "") + ] + assert reveal_messages, "expected reveal_type output from the type checker" + for message in reveal_messages: + assert "Unknown" not in message, message + assert 'is "Any"' not in message, message diff --git a/tests/typing/top_level_api.py b/tests/typing/top_level_api.py new file mode 100644 index 0000000000..4d4a21a79b --- /dev/null +++ b/tests/typing/top_level_api.py @@ -0,0 +1,31 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Static smoke test for DeepLabCut's top-level public API. + +This file is analyzed by a type checker (Pyright/basedpyright) rather than +executed by pytest. The ``reveal_type`` calls below must resolve to real +signatures — never ``Any`` or ``Unknown`` — which proves that +``deeplabcut/__init__.pyi`` exposes the lazy exports statically. +""" + +import deeplabcut + +reveal_type(deeplabcut.analyze_images) +reveal_type(deeplabcut.analyze_videos) +reveal_type(deeplabcut.train_network) +reveal_type(deeplabcut.evaluate_network) +reveal_type(deeplabcut.create_new_project) +reveal_type(deeplabcut.create_training_dataset) +reveal_type(deeplabcut.Engine) +reveal_type(deeplabcut.VERSION) +reveal_type(deeplabcut.DEBUG) +reveal_type(deeplabcut.launch_dlc) +reveal_type(deeplabcut.transformer_reID) diff --git a/uv.lock b/uv.lock index d6da389dad..3c61c6528b 100644 --- a/uv.lock +++ b/uv.lock @@ -1319,6 +1319,7 @@ dependencies = [ { name = "huggingface-hub" }, { name = "imageio-ffmpeg" }, { name = "imgaug" }, + { name = "lazy-loader" }, { name = "matplotlib" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" }, @@ -1464,6 +1465,7 @@ requires-dist = [ { name = "imageio-ffmpeg" }, { name = "imgaug", specifier = ">=0.4" }, { name = "jupyter-book", marker = "extra == 'docs'", specifier = "==1.0.4.post1" }, + { name = "lazy-loader", specifier = ">=0.4" }, { name = "matplotlib", specifier = ">=3.3,!=3.7,!=3.7.1,<3.9" }, { name = "mike", marker = "extra == 'dev-docs'", specifier = ">=2.1" }, { name = "mkdocs", marker = "extra == 'dev-docs'", specifier = ">=1.6" },