diff --git a/pysweb/swb/__init__.py b/pysweb/swb/__init__.py index c071465..1d51a2b 100644 --- a/pysweb/swb/__init__.py +++ b/pysweb/swb/__init__.py @@ -3,7 +3,7 @@ Objective: Expose SWB package entry points while preserving callable facade attributes across import order. Author: Yi Yu (with assistance from Codex) Created: 2026-04-19 -Last updated: 2026-04-19 +Last updated: 2026-10-17 Inputs: Package attribute access and submodule imports. Outputs: Callable package facade entry points. Usage: import pysweb.swb @@ -21,10 +21,7 @@ def __getattr__(name): if name == "calibrate": return import_module("pysweb.swb.calibrate") if name == "run": - api = import_module("pysweb.swb.api") - value = api.run - globals()[name] = value - return value + return import_module("pysweb.swb.run") raise AttributeError(f"module 'pysweb.swb' has no attribute {name!r}") diff --git a/pysweb/swb/api.py b/pysweb/swb/api.py index 93e481d..edc060e 100644 --- a/pysweb/swb/api.py +++ b/pysweb/swb/api.py @@ -3,7 +3,7 @@ Objective: Dispatch public SWB preprocessing, calibration and simulation calls to package workflows. Author: Yi Yu (with assistance from Codex) Created: 2026-04-17 -Last updated: 2026-10-11 +Last updated: 2026-10-17 Inputs: Keyword arguments describing workflow inputs, outputs and model settings. Outputs: Preprocessing results, calibration parameters or the simulation output path. Usage: import pysweb.swb.api @@ -12,7 +12,6 @@ from __future__ import annotations from importlib import import_module -import sys from pysweb.swb.run import run_swb_workflow @@ -53,8 +52,3 @@ def run(**kwargs): raise ValueError("Missing required inputs for SWB run") return run_swb_workflow(**kwargs) - - -_PACKAGE = sys.modules.get("pysweb.swb") -if _PACKAGE is not None: - _PACKAGE.run = run diff --git a/pysweb/swb/run.py b/pysweb/swb/run.py index 4cdbdf6..1192728 100644 --- a/pysweb/swb/run.py +++ b/pysweb/swb/run.py @@ -15,6 +15,7 @@ import argparse import multiprocessing as mp import sys +import types from concurrent.futures import ProcessPoolExecutor, as_completed from importlib import import_module from pathlib import Path @@ -806,24 +807,15 @@ def main(argv=None): run_swb_workflow(**vars(build_parser().parse_args(argv))) -def _restore_package_run_facade() -> None: - package = sys.modules.get("pysweb.swb") - if package is None: - return +class _CallableRunModule(types.ModuleType): + def __call__(self, **kwargs): + # Import machinery assigns this module to its parent's run attribute + # after executing it. Making the module callable survives that step + # and preserves both submodule aliases and the public API validation. + return import_module("pysweb.swb.api").run(**kwargs) - api_module = sys.modules.get("pysweb.swb.api") - if api_module is not None and not hasattr(api_module, "run"): - return - # Keep `import pysweb.swb.run` working while restoring `pysweb.swb.run(...)` - # as the facade callable exposed from the package root. - if api_module is None: - package.run = import_module("pysweb.swb.api").run - else: - package.run = api_module.run - - -_restore_package_run_facade() +sys.modules[__name__].__class__ = _CallableRunModule if __name__ == "__main__": diff --git a/tests/package/test_pysweb_imports.py b/tests/package/test_pysweb_imports.py index cd0a255..2d90e7c 100644 --- a/tests/package/test_pysweb_imports.py +++ b/tests/package/test_pysweb_imports.py @@ -4,7 +4,7 @@ Objective: Verify the package exposes importable subpackages and callable facade entry points. Author: Yi Yu (with assistance from Codex) Created: 2026-04-17 -Last updated: 2026-05-03 +Last updated: 2026-10-17 Inputs: Package imports and direct facade calls exercised under pytest. Outputs: Test assertions. Usage: python -m pytest tests/package/test_pysweb_imports.py @@ -113,6 +113,7 @@ def test_package_api_contracts(): [ ("pysweb.swb.preprocess", "preprocess"), ("pysweb.swb.calibrate", "calibrate"), + ("pysweb.swb.run", "run"), ], ) def test_submodule_first_import_keeps_package_entry_points_callable(submodule_name, attribute_name): @@ -131,6 +132,7 @@ def test_submodule_first_import_keeps_package_entry_points_callable(submodule_na [ ("import pysweb.swb.preprocess as preprocess_module", "preprocess_module"), ("import pysweb.swb.calibrate as calibrate_module", "calibrate_module"), + ("import pysweb.swb.run as run_module", "run_module"), ], ) def test_submodule_import_alias_remains_a_module(import_statement, module_name): @@ -149,6 +151,7 @@ def test_submodule_import_alias_remains_a_module(import_statement, module_name): [ ("preprocess",), ("calibrate",), + ("run",), ], ) def test_api_first_import_keeps_package_entry_points_as_callable_modules(attribute_name): @@ -168,6 +171,7 @@ def test_api_first_import_keeps_package_entry_points_as_callable_modules(attribu ("module_name", "arguments"), [ ("pysweb.swb.preprocess", ["--help"]), + ("pysweb.swb.run", ["--help"]), ( "pysweb.swb.calibrate", ["--help"], @@ -184,3 +188,36 @@ def test_module_cli_execution_exits_cleanly(module_name, arguments): assert result.returncode == 0 assert result.stderr == "" + + +@pytest.mark.parametrize("first_import", [ + "from pysweb.swb.run import _parser_defaults", + "from pysweb.swb.api import run", + "import pysweb.swb; pysweb.swb.run", +]) +def test_run_dispatch_and_validation_survive_fresh_import_order(first_import): + # Reproduce the production sequence in a fresh interpreter, independently + # of pytest's collection imports and earlier API attribute accesses. + script = first_import + "\n" + ''' +import importlib +import inspect +import pysweb.swb +api = importlib.import_module("pysweb.swb.api") +module = importlib.import_module("pysweb.swb.run") +assert inspect.ismodule(module) and callable(module) +assert pysweb.swb.run is module +try: + pysweb.swb.run() +except ValueError as error: + assert str(error) == "Missing required inputs for SWB run" +else: + raise AssertionError("Missing-input validation was bypassed") +calls = [] +api.run_swb_workflow = lambda **kwargs: calls.append(kwargs) or "accepted" +assert pysweb.swb.run(et="forcing.nc", workers=2) == "accepted" +assert module(et="forcing.nc", workers=2) == "accepted" +assert calls == [dict(et="forcing.nc", workers=2)] * 2 +''' + result = subprocess.run([sys.executable, "-c", script], + capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr