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.
- ahi_core-0.2.0/.gitignore +25 -0
- ahi_core-0.2.0/CHANGELOG.md +8 -0
- ahi_core-0.2.0/PKG-INFO +32 -0
- ahi_core-0.2.0/README.md +19 -0
- ahi_core-0.2.0/pyproject.toml +24 -0
- ahi_core-0.2.0/src/ahi/core/__init__.py +7 -0
- ahi_core-0.2.0/src/ahi/core/errors.py +15 -0
- ahi_core-0.2.0/src/ahi/core/logging.py +30 -0
- ahi_core-0.2.0/src/ahi/core/polling.py +61 -0
- ahi_core-0.2.0/tests/test_logging.py +22 -0
- ahi_core-0.2.0/tests/test_polling.py +84 -0
|
@@ -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))
|
ahi_core-0.2.0/PKG-INFO
ADDED
|
@@ -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).
|
ahi_core-0.2.0/README.md
ADDED
|
@@ -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
|