ahi-core 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ # Python artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ *.egg-info/
6
+ .dist/
7
+ build/
8
+ dist/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+
13
+ # Tool caches
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # IDEs/OS
21
+ .DS_Store
22
+ .vscode/*.code-snippets
23
+
24
+ # Docs build
25
+ site/
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/adaptivehealthintelligence/ahi-python-packages/compare/ahi-core-v0.1.0...ahi-core-v0.2.0) (2026-06-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * scaffold uv workspace with core, sql, aws packages ([8c42f2f](https://github.com/adaptivehealthintelligence/ahi-python-packages/commit/8c42f2f371284180bf0569785837640e39deb80b))
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: ahi-core
3
+ Version: 0.2.0
4
+ Summary: Cross-cutting helpers shared across AHI Python libraries (polling, errors, logging).
5
+ Project-URL: Homepage, https://github.com/adaptivehealthintelligence/ahi-python-packages
6
+ Project-URL: Documentation, https://adaptivehealthintelligence.github.io/ahi-python-packages/
7
+ Project-URL: Repository, https://github.com/adaptivehealthintelligence/ahi-python-packages
8
+ Author: Adaptive Health Intelligence
9
+ License: MIT
10
+ Keywords: ahi,utilities
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # ahi-core
15
+
16
+ Cross-cutting helpers shared across the AHI Python libraries. Deliberately
17
+ dependency-light — it should be safe to depend on from anywhere.
18
+
19
+ ```python
20
+ from ahi.core import get_logger, poll_until, AhiError, PollTimeoutError
21
+ ```
22
+
23
+ ## What's here
24
+
25
+ - `poll_until(...)` — a generic "submit then wait for a terminal state" loop with
26
+ exponential backoff and an injectable clock (used by `ahi-aws`).
27
+ - `get_logger(name)` — a namespaced logger under `ahi.*` with a `NullHandler`
28
+ attached, so libraries never configure global logging.
29
+ - `AhiError` — base exception for everything in the AHI stack;
30
+ `PollTimeoutError` is raised by `poll_until`.
31
+
32
+ Part of [ahi-python-packages](https://github.com/adaptivehealthintelligence/ahi-python-packages).
@@ -0,0 +1,19 @@
1
+ # ahi-core
2
+
3
+ Cross-cutting helpers shared across the AHI Python libraries. Deliberately
4
+ dependency-light — it should be safe to depend on from anywhere.
5
+
6
+ ```python
7
+ from ahi.core import get_logger, poll_until, AhiError, PollTimeoutError
8
+ ```
9
+
10
+ ## What's here
11
+
12
+ - `poll_until(...)` — a generic "submit then wait for a terminal state" loop with
13
+ exponential backoff and an injectable clock (used by `ahi-aws`).
14
+ - `get_logger(name)` — a namespaced logger under `ahi.*` with a `NullHandler`
15
+ attached, so libraries never configure global logging.
16
+ - `AhiError` — base exception for everything in the AHI stack;
17
+ `PollTimeoutError` is raised by `poll_until`.
18
+
19
+ Part of [ahi-python-packages](https://github.com/adaptivehealthintelligence/ahi-python-packages).
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "ahi-core"
3
+ version = "0.2.0"
4
+ description = "Cross-cutting helpers shared across AHI Python libraries (polling, errors, logging)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Adaptive Health Intelligence" }]
9
+ keywords = ["ahi", "utilities"]
10
+ dependencies = []
11
+
12
+ [project.urls]
13
+ Homepage = "https://github.com/adaptivehealthintelligence/ahi-python-packages"
14
+ Documentation = "https://adaptivehealthintelligence.github.io/ahi-python-packages/"
15
+ Repository = "https://github.com/adaptivehealthintelligence/ahi-python-packages"
16
+
17
+ [build-system]
18
+ requires = ["hatchling>=1.24"]
19
+ build-backend = "hatchling.build"
20
+
21
+ # src-layout namespace package: ships the `ahi/core/` subtree under the shared
22
+ # `ahi` namespace. There is intentionally no `ahi/__init__.py` (PEP 420).
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/ahi"]
@@ -0,0 +1,7 @@
1
+ """Cross-cutting helpers shared across AHI Python libraries."""
2
+
3
+ from ahi.core.errors import AhiError, PollTimeoutError
4
+ from ahi.core.logging import get_logger
5
+ from ahi.core.polling import poll_until
6
+
7
+ __all__ = ["AhiError", "PollTimeoutError", "get_logger", "poll_until"]
@@ -0,0 +1,15 @@
1
+ """Base error types shared across AHI libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AhiError(Exception):
7
+ """Base class for all errors raised by AHI libraries.
8
+
9
+ Catching ``AhiError`` lets callers handle anything this stack raises
10
+ without depending on the concrete subclasses.
11
+ """
12
+
13
+
14
+ class PollTimeoutError(AhiError, TimeoutError):
15
+ """Raised when :func:`ahi.core.polling.poll_until` exceeds its deadline."""
@@ -0,0 +1,30 @@
1
+ """Minimal logging helper.
2
+
3
+ Libraries should not configure global logging; they should obtain a named
4
+ logger and leave handler/level configuration to the consuming application.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ _PACKAGE_ROOT = "ahi"
12
+
13
+
14
+ def get_logger(name: str | None = None) -> logging.Logger:
15
+ """Return a logger under the shared ``ahi`` namespace.
16
+
17
+ A :class:`~logging.NullHandler` is attached to the package-root logger so
18
+ that libraries never emit "No handlers could be found" warnings when the
19
+ host application has not configured logging.
20
+
21
+ Args:
22
+ name: Dotted suffix (for example ``"aws.redshift"``). When omitted, the
23
+ package-root logger is returned.
24
+ """
25
+ root = logging.getLogger(_PACKAGE_ROOT)
26
+ if not any(isinstance(h, logging.NullHandler) for h in root.handlers):
27
+ root.addHandler(logging.NullHandler())
28
+ if not name:
29
+ return root
30
+ return root.getChild(name)
@@ -0,0 +1,61 @@
1
+ """A small, dependency-free poller for "start then wait for terminal state" APIs.
2
+
3
+ Both the Redshift Data API and Athena follow the same shape: submit a statement,
4
+ then poll a status endpoint until it reaches a terminal state. ``poll_until``
5
+ captures that loop once, with exponential backoff and an injectable clock so it
6
+ stays trivially testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Callable
13
+ from typing import TypeVar
14
+
15
+ from ahi.core.errors import PollTimeoutError
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def poll_until(
21
+ fetch: Callable[[], T],
22
+ is_done: Callable[[T], bool],
23
+ *,
24
+ timeout: float = 300.0,
25
+ interval: float = 1.0,
26
+ max_interval: float = 30.0,
27
+ backoff: float = 1.5,
28
+ sleep: Callable[[float], None] = time.sleep,
29
+ monotonic: Callable[[], float] = time.monotonic,
30
+ ) -> T:
31
+ """Call ``fetch`` until ``is_done`` returns ``True`` or ``timeout`` elapses.
32
+
33
+ The result is fetched once before the first sleep, so a call that is already
34
+ in a terminal state returns immediately without waiting.
35
+
36
+ Args:
37
+ fetch: Zero-arg callable returning the latest status payload.
38
+ is_done: Predicate deciding whether ``fetch``'s result is terminal.
39
+ timeout: Maximum seconds to wait before raising.
40
+ interval: Initial delay between polls, in seconds.
41
+ max_interval: Upper bound for the (growing) delay.
42
+ backoff: Multiplier applied to the delay after each poll.
43
+ sleep: Sleep function (injectable for tests).
44
+ monotonic: Monotonic clock (injectable for tests).
45
+
46
+ Returns:
47
+ The first ``fetch`` result for which ``is_done`` is ``True``.
48
+
49
+ Raises:
50
+ PollTimeoutError: If the deadline passes before a terminal state.
51
+ """
52
+ deadline = monotonic() + timeout
53
+ delay = interval
54
+ while True:
55
+ result = fetch()
56
+ if is_done(result):
57
+ return result
58
+ if monotonic() >= deadline:
59
+ raise PollTimeoutError(f"Polling exceeded timeout of {timeout:.1f}s")
60
+ sleep(min(delay, max_interval))
61
+ delay *= backoff
@@ -0,0 +1,22 @@
1
+ import logging
2
+
3
+ from ahi.core import get_logger
4
+
5
+
6
+ def test_root_logger_has_null_handler() -> None:
7
+ logger = get_logger()
8
+ assert logger.name == "ahi"
9
+ assert any(isinstance(h, logging.NullHandler) for h in logger.handlers)
10
+
11
+
12
+ def test_child_logger_namespacing() -> None:
13
+ logger = get_logger("aws.redshift")
14
+ assert logger.name == "ahi.aws.redshift"
15
+
16
+
17
+ def test_null_handler_added_once() -> None:
18
+ before = len(logging.getLogger("ahi").handlers)
19
+ get_logger()
20
+ get_logger("aws")
21
+ after = len(logging.getLogger("ahi").handlers)
22
+ assert after == before
@@ -0,0 +1,84 @@
1
+ import pytest
2
+
3
+ from ahi.core import PollTimeoutError, poll_until
4
+
5
+
6
+ class FakeClock:
7
+ """Deterministic monotonic clock advanced by the injected sleep."""
8
+
9
+ def __init__(self) -> None:
10
+ self.t = 0.0
11
+
12
+ def monotonic(self) -> float:
13
+ return self.t
14
+
15
+ def sleep(self, seconds: float) -> None:
16
+ self.t += seconds
17
+
18
+
19
+ def test_returns_immediately_when_already_done() -> None:
20
+ calls = []
21
+
22
+ def fetch() -> str:
23
+ calls.append(1)
24
+ return "FINISHED"
25
+
26
+ result = poll_until(fetch, lambda s: s == "FINISHED", sleep=lambda _: None)
27
+
28
+ assert result == "FINISHED"
29
+ assert len(calls) == 1 # no sleep, single fetch
30
+
31
+
32
+ def test_polls_until_terminal_state() -> None:
33
+ states = iter(["RUNNING", "RUNNING", "FINISHED"])
34
+ clock = FakeClock()
35
+
36
+ result = poll_until(
37
+ lambda: next(states),
38
+ lambda s: s == "FINISHED",
39
+ interval=1.0,
40
+ backoff=2.0,
41
+ sleep=clock.sleep,
42
+ monotonic=clock.monotonic,
43
+ )
44
+
45
+ assert result == "FINISHED"
46
+ assert clock.t == pytest.approx(3.0) # slept 1s then 2s
47
+
48
+
49
+ def test_raises_on_timeout() -> None:
50
+ clock = FakeClock()
51
+
52
+ with pytest.raises(PollTimeoutError):
53
+ poll_until(
54
+ lambda: "RUNNING",
55
+ lambda s: s == "FINISHED",
56
+ timeout=5.0,
57
+ interval=1.0,
58
+ backoff=1.0,
59
+ sleep=clock.sleep,
60
+ monotonic=clock.monotonic,
61
+ )
62
+
63
+
64
+ def test_delay_is_capped_at_max_interval() -> None:
65
+ clock = FakeClock()
66
+ slept: list[float] = []
67
+
68
+ def record_sleep(seconds: float) -> None:
69
+ slept.append(seconds)
70
+ clock.sleep(seconds)
71
+
72
+ states = iter(["RUNNING", "RUNNING", "RUNNING", "FINISHED"])
73
+ poll_until(
74
+ lambda: next(states),
75
+ lambda s: s == "FINISHED",
76
+ interval=10.0,
77
+ max_interval=15.0,
78
+ backoff=10.0,
79
+ timeout=1000.0,
80
+ sleep=record_sleep,
81
+ monotonic=clock.monotonic,
82
+ )
83
+
84
+ assert slept == [10.0, 15.0, 15.0] # capped after the first poll