From a002ea479891b3b0205eaa53b137c6b43303f640 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:56:26 +0930 Subject: [PATCH 1/3] Add dynamic API facade --- map2loop/__init__.py | 1 + map2loop/api.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 map2loop/api.py create mode 100644 tests/test_api.py diff --git a/map2loop/__init__.py b/map2loop/__init__.py index 8723f4ef..6b0912e5 100644 --- a/map2loop/__init__.py +++ b/map2loop/__init__.py @@ -6,6 +6,7 @@ ch.setFormatter(formatter) ch.setLevel(logging.WARNING) from .project import Project +from .api import Map2LoopAPI from .version import __version__ import warnings # TODO: convert warnings to logging diff --git a/map2loop/api.py b/map2loop/api.py new file mode 100644 index 00000000..665ee3b7 --- /dev/null +++ b/map2loop/api.py @@ -0,0 +1,81 @@ +"""Public API for map2loop +========================== + +This module provides a stable interface to dynamically access classes within the +``map2loop`` package. It exposes a :class:`Map2LoopAPI` facade which can be used +by external applications, e.g. a QGIS plugin, to instantiate map2loop classes +without relying on the internal module layout. Classes are discovered using +Python's introspection facilities the first time the API is used. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +import pathlib +from types import ModuleType +from typing import Any, Dict, List, Type + + +class Map2LoopAPI: + """Facade exposing map2loop classes. + + The API implements a dynamic factory that discovers classes from the + ``map2loop`` package. It allows creating instances of classes by name and + retrieving class objects directly. Because discovery happens at runtime, + external clients remain agnostic to refactoring inside the package. + """ + + def __init__(self) -> None: + self._class_map: Dict[str, Type[Any]] = {} + self._discover_classes() + + # ------------------------------------------------------------------ + def _discover_classes(self) -> None: + """Populate ``_class_map`` with classes found in ``map2loop``.""" + package_dir = pathlib.Path(__file__).parent + for module_info in pkgutil.iter_modules([str(package_dir)]): + name = module_info.name + if name.startswith("_") or name == "api": + continue # skip private modules and this file + module = importlib.import_module(f"map2loop.{name}") + self._register_classes(module) + + def _register_classes(self, module: ModuleType) -> None: + for attr_name in dir(module): + attr = getattr(module, attr_name) + if inspect.isclass(attr): + self._class_map[attr.__name__] = attr + + # ------------------------------------------------------------------ + def list_classes(self) -> List[str]: + """Return names of available classes.""" + return sorted(self._class_map.keys()) + + # ------------------------------------------------------------------ + def get_class(self, class_name: str) -> Type[Any]: + """Return class object by name. + + Parameters + ---------- + class_name: + Name of the class as exposed by the API. + """ + cls = self._class_map.get(class_name) + if cls is None: + # If a new class was added after initialisation, attempt to reload. + self._discover_classes() + cls = self._class_map.get(class_name) + if cls is None: + raise ValueError(f"Class '{class_name}' not found in map2loop package") + return cls + + # ------------------------------------------------------------------ + def create(self, class_name: str, *args: Any, **kwargs: Any) -> Any: + """Instantiate a class by name.""" + cls = self.get_class(class_name) + return cls(*args, **kwargs) + + +__all__ = ["Map2LoopAPI"] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..69bcbfb9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,61 @@ +import pathlib +import pytest +from map2loop.project import Project + +from map2loop.api import Map2LoopAPI + + +def test_api_list_and_get_class(): + api = Map2LoopAPI() + classes = api.list_classes() + assert "MapData" in classes + mapdata_cls = api.get_class("MapData") + assert mapdata_cls.__name__ == "MapData" + + +def test_api_create_instance(): + api = Map2LoopAPI() + obj = api.create("MapData") + from map2loop.mapdata import MapData + + assert isinstance(obj, MapData) + + +def test_api_create_project(tmp_path): + api = Map2LoopAPI() + import map2loop + + bbox = { + "minx": 515687.31005864, + "miny": 7493446.76593407, + "maxx": 562666.860106543, + "maxy": 7521273.57407786, + "base": -3200, + "top": 3000, + } + geology_file = ( + pathlib.Path(map2loop.__file__).parent + / "_datasets" / "geodata_files" / "hamersley" / "geology.geojson" + ) + structure_file = ( + pathlib.Path(map2loop.__file__).parent + / "_datasets" / "geodata_files" / "hamersley" / "structures.geojson" + ) + dtm_file = ( + pathlib.Path(map2loop.__file__).parent + / "_datasets" / "geodata_files" / "hamersley" / "dtm_rp.tif" + ) + config_dict = { + "structure": {"dipdir_column": "azimuth2", "dip_column": "dip"}, + "geology": {"unitname_column": "unitname", "alt_unitname_column": "code"}, + } + project = api.create( + "Project", + bounding_box=bbox, + working_projection="EPSG:28350", + geology_filename=str(geology_file), + dtm_filename=str(dtm_file), + structure_filename=str(structure_file), + config_dictionary=config_dict, + ) + assert isinstance(project, Project) From ef84cfae5a3fc097a3d3590d68b7a61c5c51b708 Mon Sep 17 00:00:00 2001 From: rabii-chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 25 Jun 2025 05:26:46 +0000 Subject: [PATCH 2/3] style: style fixes by ruff and autoformatting by black --- tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 69bcbfb9..e22293bd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,4 @@ import pathlib -import pytest from map2loop.project import Project from map2loop.api import Map2LoopAPI From 9dd035ee0378261946fb0fd033a7e7ac457ed29e Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:00:07 +0930 Subject: [PATCH 3/3] Update map2loop/api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- map2loop/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/map2loop/api.py b/map2loop/api.py index 665ee3b7..7cf5368b 100644 --- a/map2loop/api.py +++ b/map2loop/api.py @@ -45,7 +45,7 @@ def _discover_classes(self) -> None: def _register_classes(self, module: ModuleType) -> None: for attr_name in dir(module): attr = getattr(module, attr_name) - if inspect.isclass(attr): + if inspect.isclass(attr) and attr.__module__ == module.__name__: self._class_map[attr.__name__] = attr # ------------------------------------------------------------------