From 9dec2c8e206eafc99ba38aba38630a10327ea62f Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 5 Nov 2026 12:41:49 +0000 Subject: [PATCH] feat: import autocti without arcticpy (#117) arcticpy is now imported lazily, so `import autocti` works on machines without it: datasets, layouts, masks, plotting and loading results all keep working. - autocti/_arcticpy.py: an arcticpy() accessor that raises an ImportError naming the install recipe, plus optional_import(), which returns the real arcticpy objects or, without arcticpy, placeholders that raise that error when called. The placeholders are real classes, so PyAutoFit can still evaluate constructor type hints. - autocti/__init__.py: the nine arcticpy re-exports (ac.TrapInstantCapture, ac.CCDPhase, ac.ROE, ...) resolve on first use through a PEP 562 __getattr__. - clocker/, model/ and ou_sim_ci.py use optional_import instead of a module-level `from arcticpy import`. With arcticpy installed, every name is the same arcticpy object as before. Co-Authored-By: Claude Opus 5.5 --- autocti/__init__.py | 30 ++++++---- autocti/_arcticpy.py | 81 +++++++++++++++++++++++++++ autocti/charge_injection/ou_sim_ci.py | 5 +- autocti/clocker/abstract.py | 5 +- autocti/clocker/one_d.py | 8 +-- autocti/clocker/two_d.py | 5 +- autocti/model/model_util.py | 8 ++- autocti/model/settings.py | 4 +- pyproject.toml | 3 +- test_autocti/test_lazy_arcticpy.py | 80 ++++++++++++++++++++++++++ 10 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 autocti/_arcticpy.py create mode 100644 test_autocti/test_lazy_arcticpy.py diff --git a/autocti/__init__.py b/autocti/__init__.py index c1fd2c56..18672c4f 100644 --- a/autocti/__init__.py +++ b/autocti/__init__.py @@ -10,16 +10,6 @@ from autoarray.dataset import preprocess from autoarray.dataset.imaging.dataset import Imaging -from arcticpy import ROE -from arcticpy import ROEChargeInjection -from arcticpy import CCD -from arcticpy import CCDPhase -from arcticpy import TrapInstantCapture -from arcticpy import TrapSlowCapture -from arcticpy import TrapInstantCaptureContinuum -from arcticpy import TrapSlowCaptureContinuum -from arcticpy import PixelBounce - from .charge_injection.fit import FitImagingCI from .charge_injection.hyper import HyperCINoiseScalar from .charge_injection.hyper import HyperCINoiseCollection @@ -110,3 +100,23 @@ is_test_mode, test_mode_level, ) + +# --------------------------------------------------------------------------- +# arcticpy re-exports, resolved lazily (PEP 562). +# +# ``ac.TrapInstantCapture``, ``ac.CCDPhase``, ``ac.ROE`` and friends are the real +# arcticpy classes, but arcticpy is imported only when one of them is first +# used, so ``import autocti`` works on machines without arcticpy installed. +# Without it, using one raises an ImportError naming the install recipe. +# --------------------------------------------------------------------------- +from autocti import _arcticpy + + +def __getattr__(name): + if name in _arcticpy.REEXPORTS: + return getattr(_arcticpy.arcticpy(), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(_arcticpy.REEXPORTS)) diff --git a/autocti/_arcticpy.py b/autocti/_arcticpy.py new file mode 100644 index 00000000..282d5168 --- /dev/null +++ b/autocti/_arcticpy.py @@ -0,0 +1,81 @@ +""" +Deferred access to arcticpy, the C++ arctic clocking code. + +arcticpy ships only as source: building it needs GSL and a C++ toolchain, so it +is not a pip dependency of autocti. Importing it lazily means ``import autocti`` +works without it — datasets, layouts, masks, plotting and loading results all +run — and only clocking, or the arcticpy classes autocti re-exports +(``ac.TrapInstantCapture``, ``ac.CCDPhase``, ``ac.ROE``, ...), need it. +""" + +import importlib +from types import ModuleType + +INSTALL_HINT = ( + "autocti needs arcticpy (the arctic CTI clocking code) for this, and it is " + "not installed. arcticpy has no wheel on PyPI, so install it with the " + "PyAutoLabs recipe, which builds it from source (it needs GSL and a C++ " + "compiler):\n\n" + " https://github.com/PyAutoLabs/PyAutoHeart/blob/main/.github/actions/install-arcticpy/install_arcticpy.sh\n\n" + "See the autocti installation docs for the full steps." +) + +# The arcticpy names autocti re-exports as part of its own public API. +REEXPORTS = ( + "ROE", + "ROEChargeInjection", + "CCD", + "CCDPhase", + "TrapInstantCapture", + "TrapSlowCapture", + "TrapInstantCaptureContinuum", + "TrapSlowCaptureContinuum", + "PixelBounce", +) + + +def arcticpy() -> ModuleType: + """ + Return the arcticpy module, importing it on first use. + + Raises + ------ + ImportError + If arcticpy is not installed, with the install recipe in the message. + """ + try: + return importlib.import_module("arcticpy") + except ImportError as e: + raise ImportError(INSTALL_HINT) from e + + +def _placeholder(name: str): + """ + Stand-in for an arcticpy class or function when arcticpy is not installed. + + It is a real class, so type hints that name it still evaluate (PyAutoFit + reads constructor annotations to build models), and calling it raises the + install hint. + """ + + def __new__(cls, *args, **kwargs): + raise ImportError(INSTALL_HINT) + + return type(name, (), {"__new__": __new__, "__module__": "arcticpy"}) + + +def optional_import(*names: str): + """ + Return the named arcticpy objects, or placeholders if arcticpy is missing. + + With arcticpy installed this is exactly ``from arcticpy import ``. + Without it, each name is a placeholder that raises the install hint when + called, so modules can import at load time and fail only when clocking. + """ + try: + module = arcticpy() + except ImportError: + objects = tuple(_placeholder(name) for name in names) + else: + objects = tuple(getattr(module, name) for name in names) + return objects[0] if len(objects) == 1 else objects diff --git a/autocti/charge_injection/ou_sim_ci.py b/autocti/charge_injection/ou_sim_ci.py index 25299989..feb346f8 100644 --- a/autocti/charge_injection/ou_sim_ci.py +++ b/autocti/charge_injection/ou_sim_ci.py @@ -2,8 +2,9 @@ import numpy as np from typing import List, Union -from arcticpy import CCDPhase -from arcticpy import TrapInstantCapture +from autocti._arcticpy import optional_import + +CCDPhase, TrapInstantCapture = optional_import("CCDPhase", "TrapInstantCapture") from autocti.instruments.euclid import euclid_util diff --git a/autocti/clocker/abstract.py b/autocti/clocker/abstract.py index 2ba27b9a..b7a2a21e 100644 --- a/autocti/clocker/abstract.py +++ b/autocti/clocker/abstract.py @@ -1,5 +1,6 @@ -from arcticpy import CCD -from arcticpy import CCDPhase +from autocti._arcticpy import optional_import + +CCD, CCDPhase = optional_import("CCD", "CCDPhase") from autonerves.dictable import from_json, output_to_json diff --git a/autocti/clocker/one_d.py b/autocti/clocker/one_d.py index ad88f702..9f84d779 100644 --- a/autocti/clocker/one_d.py +++ b/autocti/clocker/one_d.py @@ -1,10 +1,10 @@ from typing import List, Optional -from arcticpy import add_cti -from arcticpy import remove_cti +from autocti._arcticpy import optional_import -from arcticpy import ROE -from arcticpy import PixelBounce +add_cti, remove_cti, ROE, PixelBounce = optional_import( + "add_cti", "remove_cti", "ROE", "PixelBounce" +) import autoarray as aa diff --git a/autocti/clocker/two_d.py b/autocti/clocker/two_d.py index e209b378..2fec40ba 100644 --- a/autocti/clocker/two_d.py +++ b/autocti/clocker/two_d.py @@ -1,10 +1,9 @@ import numpy as np from typing import List, Optional -from arcticpy import add_cti -from arcticpy import remove_cti +from autocti._arcticpy import optional_import -from arcticpy import ROE +add_cti, remove_cti, ROE = optional_import("add_cti", "remove_cti", "ROE") import autoarray as aa diff --git a/autocti/model/model_util.py b/autocti/model/model_util.py index 43391aeb..b463d27b 100644 --- a/autocti/model/model_util.py +++ b/autocti/model/model_util.py @@ -2,9 +2,11 @@ from autonerves.dictable import from_json -from arcticpy import CCDPhase -from arcticpy import PixelBounce -from arcticpy import TrapInstantCapture +from autocti._arcticpy import optional_import + +CCDPhase, PixelBounce, TrapInstantCapture = optional_import( + "CCDPhase", "PixelBounce", "TrapInstantCapture" +) class AbstractCTI: diff --git a/autocti/model/settings.py b/autocti/model/settings.py index f0e4d093..23473c6c 100644 --- a/autocti/model/settings.py +++ b/autocti/model/settings.py @@ -1,6 +1,8 @@ from typing import List, Optional, Tuple -from arcticpy import TrapInstantCapture +from autocti._arcticpy import optional_import + +TrapInstantCapture = optional_import("TrapInstantCapture") from autonerves.dictable import from_json, output_to_json diff --git a/pyproject.toml b/pyproject.toml index 09889831..936aa1d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,8 @@ classifiers = [ "Programming Language :: Python :: 3.13" ] keywords = ["cli"] -# arcticpy (the C++ arctic clocking code) is a hard import of autocti but is +# arcticpy (the C++ arctic clocking code) is needed for clocking, but +# `import autocti` works without it (autocti/_arcticpy.py imports it lazily). It is # deliberately NOT a pip dependency: its sdist is source-only (needs libgsl-dev # and a C++ toolchain) and its own requirements downgrade numpy below 2.0. # Install it separately with `pip install arcticpy==2.6 --no-build-isolation diff --git a/test_autocti/test_lazy_arcticpy.py b/test_autocti/test_lazy_arcticpy.py new file mode 100644 index 00000000..d345e9b2 --- /dev/null +++ b/test_autocti/test_lazy_arcticpy.py @@ -0,0 +1,80 @@ +""" +``import autocti`` must work without arcticpy (cti-upkeep ruling R2). + +Each check runs in a fresh interpreter with ``sys.modules["arcticpy"] = None``, +which makes every ``import arcticpy`` raise ImportError exactly as on a machine +where it is not installed. +""" + +import subprocess +import sys +import textwrap + +_HIDE_ARCTICPY = "import sys\nsys.modules['arcticpy'] = None\n" + + +def _run(body: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", _HIDE_ARCTICPY + textwrap.dedent(body)], + capture_output=True, + text=True, + ) + + +def test_import_autocti_works_without_arcticpy(): + result = _run(""" + import autocti as ac + + ac.Dataset1D + ac.Layout2DCI + ac.Mask1D + ac.Clocker1D + ac.CTI1D + print("ok") + """) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().endswith("ok") + + +def test_reexported_arcticpy_name_raises_install_hint_without_arcticpy(): + result = _run(""" + import autocti as ac + + try: + ac.TrapInstantCapture + except ImportError as e: + print("raised:", e) + """) + + assert result.returncode == 0, result.stderr + assert "raised:" in result.stdout + assert "install_arcticpy.sh" in result.stdout + + +def test_clocker_raises_install_hint_without_arcticpy(): + result = _run(""" + import autocti as ac + + try: + ac.Clocker1D(express=2) + except ImportError as e: + print("raised:", e) + """) + + assert result.returncode == 0, result.stderr + assert "install_arcticpy.sh" in result.stdout + + +def test_reexports_are_the_real_arcticpy_classes(): + import arcticpy + import autocti as ac + from autocti._arcticpy import REEXPORTS + + for name in REEXPORTS: + assert getattr(ac, name) is getattr(arcticpy, name) + + from autocti import TrapInstantCapture + + assert TrapInstantCapture is arcticpy.TrapInstantCapture + assert set(REEXPORTS) <= set(dir(ac))