Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions autocti/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
81 changes: 81 additions & 0 deletions autocti/_arcticpy.py
Original file line number Diff line number Diff line change
@@ -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 <names>``.
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
5 changes: 3 additions & 2 deletions autocti/charge_injection/ou_sim_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions autocti/clocker/abstract.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 4 additions & 4 deletions autocti/clocker/one_d.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 2 additions & 3 deletions autocti/clocker/two_d.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 5 additions & 3 deletions autocti/model/model_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion autocti/model/settings.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions test_autocti/test_lazy_arcticpy.py
Original file line number Diff line number Diff line change
@@ -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))
Loading