analogcloud-common 0.2.0__tar.gz → 0.2.1__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.
Files changed (42) hide show
  1. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/PKG-INFO +1 -1
  2. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/pyproject.toml +1 -1
  3. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/api/v1.py +37 -0
  4. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/celery.py +3 -0
  5. analogcloud_common-0.2.1/src/anabrid/cloud/storage/metrics.py +160 -0
  6. analogcloud_common-0.2.1/src/anabrid/cloud/version.py +89 -0
  7. analogcloud_common-0.2.1/tests/test_storage_metrics.py +109 -0
  8. analogcloud_common-0.2.1/tests/test_version.py +68 -0
  9. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/README.md +0 -0
  10. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/__init__.py +0 -0
  11. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/generate.py +0 -0
  12. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.workers-compile-remote.yaml.j2 +0 -0
  13. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.workers.yaml.j2 +0 -0
  14. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/deploy/templates/docker-compose.yaml.j2 +0 -0
  15. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/devices.py +0 -0
  16. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/__init__.py +0 -0
  17. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/api/__init__.py +0 -0
  18. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/auth.py +0 -0
  19. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/config.py +0 -0
  20. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/db.py +0 -0
  21. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/models/enums.py +0 -0
  22. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/__init__.py +0 -0
  23. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/backend.py +0 -0
  24. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/blob_handle.py +0 -0
  25. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/log_handle.py +0 -0
  26. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/session.py +0 -0
  27. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/storage/types.py +0 -0
  28. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/util/__init__.py +0 -0
  29. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/src/anabrid/cloud/util/config.py +0 -0
  30. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/__init__.py +0 -0
  31. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/conftest.py +0 -0
  32. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_blob_storage.py +0 -0
  33. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_deploy_generate.py +0 -0
  34. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_device_manager.py +0 -0
  35. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_models.py +0 -0
  36. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_priority.py +0 -0
  37. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_storage_backend.py +0 -0
  38. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_storage_backend_integration.py +0 -0
  39. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_create.py +0 -0
  40. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_delete.py +0 -0
  41. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_list.py +0 -0
  42. {analogcloud_common-0.2.0 → analogcloud_common-0.2.1}/tests/test_task_mark_cancelled.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: analogcloud-common
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Common data models and storage components for Analog Cloud
5
5
  Author-Email: Anabrid <info@anabrid.com>
6
6
  Requires-Python: <4.0,>=3.11
@@ -62,7 +62,7 @@ dependencies = [
62
62
  "aioboto3>=15.5.0",
63
63
  ]
64
64
  name = "analogcloud-common"
65
- version = "0.2.0"
65
+ version = "0.2.1"
66
66
  description = "Common data models and storage components for Analog Cloud"
67
67
  readme = "README.md"
68
68
 
@@ -30,6 +30,43 @@ class SystemStatusResponse(BaseModel):
30
30
  uptime: int
31
31
 
32
32
 
33
+ class ComponentVersion(BaseModel):
34
+ """Build identity of one running component (server or worker)."""
35
+
36
+ component: str
37
+ version: str
38
+ git_sha: str | None = None
39
+ git_tag: str | None = None
40
+ environment: str | None = None
41
+ hostname: str | None = None
42
+
43
+
44
+ class WorkerVersion(ComponentVersion):
45
+ """Build identity of one worker process, as reported over Celery remote control.
46
+
47
+ ``dependencies`` carries the installed versions of the worker's notable
48
+ third-party packages (the compiler and the device library).
49
+ """
50
+
51
+ worker_type: str | None = None
52
+ queue: str | None = None
53
+ dependencies: dict[str, str | None] = Field(default_factory=dict)
54
+ error: str | None = None
55
+
56
+
57
+ class SystemVersionResponse(BaseModel):
58
+ """GET /api/v1/system/version response.
59
+
60
+ ``workers`` lists every worker that answered the broadcast within the
61
+ timeout; ``workers_reachable`` is ``False`` when the broker could not be
62
+ asked at all, which is different from no worker answering.
63
+ """
64
+
65
+ server: ComponentVersion
66
+ workers: list[WorkerVersion]
67
+ workers_reachable: bool
68
+
69
+
33
70
  class DeviceResponse(BaseModel):
34
71
  """Device information for GET /api/v1/devices responses.
35
72
 
@@ -5,6 +5,9 @@ COMPILE_QUEUE = "compilation"
5
5
  RUN_TASK_NAME = "run"
6
6
  # Run queue is the device name (dynamic, no constant).
7
7
 
8
+ # Celery remote-control command every worker answers with its build identity.
9
+ WORKER_VERSION_COMMAND = "analogcloud_version"
10
+
8
11
  RABBIT_MAX_PRIORITY = 10
9
12
  RABBIT_DEFAULT_PRIORITY = 1
10
13
 
@@ -0,0 +1,160 @@
1
+ """Aggregate task statistics for the metrics endpoint.
2
+
3
+ Everything here is derived from the ``task_states`` history table, which
4
+ records one timestamped row per state transition. Waiting time is the
5
+ distance from a task's QUEUED row to its first INPROGRESS row; working
6
+ time is the distance from that INPROGRESS row to the task's last terminal
7
+ row. Totals are cumulative over the whole table, which makes them
8
+ monotonic as long as history is kept and lets a scraper take rates.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ from sqlalchemy import and_, extract, func, select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from ..models.db import (
19
+ RunTask,
20
+ Task,
21
+ TaskState,
22
+ latest_task_state_subquery,
23
+ queued_task_state_subquery,
24
+ )
25
+ from ..models.enums import TaskStateEnum
26
+
27
+ ACTIVE_STATES: tuple[TaskStateEnum, ...] = (
28
+ TaskStateEnum.QUEUED,
29
+ TaskStateEnum.INPROGRESS,
30
+ )
31
+ TERMINAL_STATES: tuple[TaskStateEnum, ...] = (
32
+ TaskStateEnum.SUCCEEDED,
33
+ TaskStateEnum.FAILED,
34
+ TaskStateEnum.CANCELLED,
35
+ )
36
+ TASK_TYPES: tuple[str, ...] = ("compile", "run")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DurationStats:
41
+ """Count and summed seconds of one group of durations."""
42
+
43
+ count: int
44
+ total_seconds: float
45
+
46
+
47
+ @dataclass
48
+ class TaskStatistics:
49
+ """Snapshot of task counts and durations, keyed by label tuples.
50
+
51
+ ``device`` is the run task's device name, or ``""`` for compile tasks.
52
+ """
53
+
54
+ current: dict[tuple[str, str], int] = field(default_factory=dict)
55
+ """``(type, state)`` -> number of tasks currently in that active state."""
56
+
57
+ finished: dict[tuple[str, str], int] = field(default_factory=dict)
58
+ """``(type, state)`` -> number of terminal transitions recorded."""
59
+
60
+ wait: dict[tuple[str, str], DurationStats] = field(default_factory=dict)
61
+ """``(type, device)`` -> QUEUED to INPROGRESS durations."""
62
+
63
+ work: dict[tuple[str, str, str], DurationStats] = field(default_factory=dict)
64
+ """``(type, device, terminal state)`` -> INPROGRESS to terminal durations."""
65
+
66
+
67
+ def _state_value(state: object) -> str:
68
+ return state.value if isinstance(state, TaskStateEnum) else str(state)
69
+
70
+
71
+ async def collect_task_statistics(session: AsyncSession) -> TaskStatistics:
72
+ """Run the aggregate queries and return a :class:`TaskStatistics`."""
73
+ stats = TaskStatistics()
74
+ latest = latest_task_state_subquery()
75
+ # The run_tasks table itself rather than the RunTask entity: the entity
76
+ # shares the tasks table through joined inheritance, and joining it to
77
+ # Task makes SQLAlchemy alias it, which the GROUP BY cannot follow.
78
+ run_tasks = RunTask.__table__
79
+ device = func.coalesce(run_tasks.c.device_name, "")
80
+
81
+ current_stmt = (
82
+ select(Task.type, latest.c.state, func.count())
83
+ .join(latest, latest.c.task_id == Task.id)
84
+ .where(latest.c.rn == 1, latest.c.state.in_(ACTIVE_STATES))
85
+ .group_by(Task.type, latest.c.state)
86
+ )
87
+ for task_type, state, count in (await session.execute(current_stmt)).all():
88
+ stats.current[(task_type, _state_value(state))] = int(count)
89
+
90
+ finished_stmt = (
91
+ select(Task.type, TaskState.state, func.count())
92
+ .join(TaskState, TaskState.task_id == Task.id)
93
+ .where(TaskState.state.in_(TERMINAL_STATES))
94
+ .group_by(Task.type, TaskState.state)
95
+ )
96
+ for task_type, state, count in (await session.execute(finished_stmt)).all():
97
+ stats.finished[(task_type, _state_value(state))] = int(count)
98
+
99
+ queued = queued_task_state_subquery()
100
+ started = (
101
+ select(
102
+ TaskState.task_id.label("task_id"),
103
+ func.min(TaskState.timestamp).label("started_at"),
104
+ )
105
+ .where(TaskState.state == TaskStateEnum.INPROGRESS)
106
+ .group_by(TaskState.task_id)
107
+ .subquery("started_states")
108
+ )
109
+ ended = (
110
+ select(
111
+ TaskState.task_id.label("task_id"),
112
+ func.max(TaskState.timestamp).label("ended_at"),
113
+ )
114
+ .where(TaskState.state.in_(TERMINAL_STATES))
115
+ .group_by(TaskState.task_id)
116
+ .subquery("ended_states")
117
+ )
118
+
119
+ wait_stmt = (
120
+ select(
121
+ Task.type,
122
+ device,
123
+ func.count(),
124
+ func.coalesce(
125
+ func.sum(extract("epoch", started.c.started_at - queued.c.created_at)),
126
+ 0.0,
127
+ ),
128
+ )
129
+ .join(queued, queued.c.task_id == Task.id)
130
+ .join(started, started.c.task_id == Task.id)
131
+ .outerjoin(run_tasks, run_tasks.c.id == Task.id)
132
+ .group_by(Task.type, device)
133
+ )
134
+ for task_type, dev, count, total in (await session.execute(wait_stmt)).all():
135
+ stats.wait[(task_type, dev)] = DurationStats(int(count), float(total))
136
+
137
+ work_stmt = (
138
+ select(
139
+ Task.type,
140
+ device,
141
+ latest.c.state,
142
+ func.count(),
143
+ func.coalesce(
144
+ func.sum(extract("epoch", ended.c.ended_at - started.c.started_at)),
145
+ 0.0,
146
+ ),
147
+ )
148
+ .join(started, started.c.task_id == Task.id)
149
+ .join(ended, ended.c.task_id == Task.id)
150
+ .join(latest, and_(latest.c.task_id == Task.id, latest.c.rn == 1))
151
+ .outerjoin(run_tasks, run_tasks.c.id == Task.id)
152
+ .where(latest.c.state.in_(TERMINAL_STATES))
153
+ .group_by(Task.type, device, latest.c.state)
154
+ )
155
+ for task_type, dev, state, count, total in (await session.execute(work_stmt)).all():
156
+ stats.work[(task_type, dev, _state_value(state))] = DurationStats(
157
+ int(count), float(total)
158
+ )
159
+
160
+ return stats
@@ -0,0 +1,89 @@
1
+ """Build identity of a running component: version, git revision, environment.
2
+
3
+ The package version is stamped at build time (``X.Y.Z`` on a release tag,
4
+ ``X.Y.Z.devN+g<sha>`` otherwise). The git revision and tag are baked into
5
+ the container images as ``ANALOGCLOUD_GIT_SHA`` / ``ANALOGCLOUD_GIT_TAG``;
6
+ the deployment names itself through ``ANALOGCLOUD_ENVIRONMENT``. When an
7
+ environment variable is absent, what can be derived from the version
8
+ string is derived, and the rest is reported as unknown (``None``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import asdict, dataclass
17
+ from importlib import metadata
18
+
19
+ _LOCAL_SHA = re.compile(r"\+g([0-9a-f]{6,40})")
20
+ _RELEASE = re.compile(r"^\d+\.\d+\.\d+$")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BuildInfo:
25
+ """Identity of one running component."""
26
+
27
+ component: str
28
+ version: str
29
+ git_sha: str | None
30
+ git_tag: str | None
31
+ environment: str | None
32
+
33
+ def as_dict(self) -> dict[str, str | None]:
34
+ """Return the fields as a plain dict (JSON- and label-friendly)."""
35
+ return asdict(self)
36
+
37
+
38
+ def distribution_version(
39
+ distribution: str,
40
+ *,
41
+ version_lookup: Callable[[str], str] = metadata.version,
42
+ ) -> str | None:
43
+ """Return the installed version of ``distribution`` or ``None`` if absent."""
44
+ try:
45
+ return version_lookup(distribution)
46
+ except metadata.PackageNotFoundError:
47
+ return None
48
+
49
+
50
+ def build_info(
51
+ component: str,
52
+ *,
53
+ distribution: str | None = None,
54
+ env: Mapping[str, str] | None = None,
55
+ version_lookup: Callable[[str], str] = metadata.version,
56
+ ) -> BuildInfo:
57
+ """Return the :class:`BuildInfo` of ``component``.
58
+
59
+ Args:
60
+ component: Short component name (``server``, ``worker``).
61
+ distribution: Installed distribution to read the version from;
62
+ defaults to ``analogcloud-<component>``.
63
+ env: Environment mapping; defaults to ``os.environ``.
64
+ version_lookup: ``importlib.metadata.version`` or a stand-in.
65
+ """
66
+ env = os.environ if env is None else env
67
+ version = (
68
+ distribution_version(
69
+ distribution or f"analogcloud-{component}", version_lookup=version_lookup
70
+ )
71
+ or "unknown"
72
+ )
73
+
74
+ git_sha = env.get("ANALOGCLOUD_GIT_SHA") or None
75
+ if git_sha is None:
76
+ match = _LOCAL_SHA.search(version)
77
+ git_sha = match.group(1) if match else None
78
+
79
+ git_tag = env.get("ANALOGCLOUD_GIT_TAG") or None
80
+ if git_tag is None and _RELEASE.match(version):
81
+ git_tag = f"v{version}"
82
+
83
+ return BuildInfo(
84
+ component=component,
85
+ version=version,
86
+ git_sha=git_sha,
87
+ git_tag=git_tag,
88
+ environment=env.get("ANALOGCLOUD_ENVIRONMENT") or None,
89
+ )
@@ -0,0 +1,109 @@
1
+ """Tests for anabrid.cloud.storage.metrics.collect_task_statistics (real Postgres)."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+
8
+ from anabrid.cloud.models.db import CompileTask, Device, RunTask, TaskState
9
+ from anabrid.cloud.models.enums import DeviceTypeEnum, TaskStateEnum
10
+ from anabrid.cloud.storage import BlobHandle
11
+ from anabrid.cloud.storage.metrics import DurationStats, collect_task_statistics
12
+
13
+ T0 = datetime(2026, 1, 1, 12, 0, 0)
14
+
15
+
16
+ def _compile_task() -> CompileTask:
17
+ return CompileTask(
18
+ user_id="u", ode_source="x' = -x", backend="redac", k0=None, module=BlobHandle(uuid4())
19
+ )
20
+
21
+
22
+ def _run_task(device: str) -> RunTask:
23
+ return RunTask(user_id="u", device_name=device, module=BlobHandle(uuid4()))
24
+
25
+
26
+ async def _insert(session_factory, task, transitions):
27
+ """Insert ``task`` with one TaskState per ``(state, seconds_after_T0)``."""
28
+ async with session_factory() as session:
29
+ session.add(task)
30
+ await session.flush()
31
+ for state, offset in transitions:
32
+ session.add(
33
+ TaskState(task_id=task.id, state=state, timestamp=T0 + timedelta(seconds=offset))
34
+ )
35
+ await session.commit()
36
+
37
+
38
+ async def _device(session_factory, name="lucidac0"):
39
+ async with session_factory() as session:
40
+ session.add(Device(name=name, type=DeviceTypeEnum.LUCIDAC, host="10.0.0.5", available=True))
41
+ await session.commit()
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_empty_database(session_factory):
46
+ """No tasks: every mapping is empty and nothing raises."""
47
+ async with session_factory() as session:
48
+ stats = await collect_task_statistics(session)
49
+ assert stats.current == {} and stats.finished == {}
50
+ assert stats.wait == {} and stats.work == {}
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_current_counts_latest_state_only(session_factory):
55
+ """A task is counted once, under its latest state."""
56
+ Q, P = TaskStateEnum.QUEUED, TaskStateEnum.INPROGRESS
57
+ await _insert(session_factory, _compile_task(), [(Q, 0)])
58
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 5)])
59
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 5), (TaskStateEnum.SUCCEEDED, 9)])
60
+ async with session_factory() as session:
61
+ stats = await collect_task_statistics(session)
62
+ assert stats.current == {("compile", "queued"): 1, ("compile", "inprogress"): 1}
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_finished_counts_by_type_and_outcome(session_factory):
67
+ """Terminal transitions are counted per task type and outcome."""
68
+ await _device(session_factory)
69
+ Q, P = TaskStateEnum.QUEUED, TaskStateEnum.INPROGRESS
70
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 1), (TaskStateEnum.SUCCEEDED, 2)])
71
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 1), (TaskStateEnum.FAILED, 2)])
72
+ await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (TaskStateEnum.CANCELLED, 1)])
73
+ await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 1), (TaskStateEnum.SUCCEEDED, 4)])
74
+ async with session_factory() as session:
75
+ stats = await collect_task_statistics(session)
76
+ assert stats.finished == {
77
+ ("compile", "succeeded"): 1,
78
+ ("compile", "failed"): 1,
79
+ ("run", "cancelled"): 1,
80
+ ("run", "succeeded"): 1,
81
+ }
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_wait_and_work_durations(session_factory):
86
+ """Wait is QUEUED->INPROGRESS, work is INPROGRESS->terminal; run tasks carry the device."""
87
+ await _device(session_factory)
88
+ Q, P, S, F = (
89
+ TaskStateEnum.QUEUED,
90
+ TaskStateEnum.INPROGRESS,
91
+ TaskStateEnum.SUCCEEDED,
92
+ TaskStateEnum.FAILED,
93
+ )
94
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 2), (S, 12)]) # wait 2, work 10
95
+ await _insert(session_factory, _compile_task(), [(Q, 0), (P, 4), (F, 5)]) # wait 4, work 1
96
+ await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 30), (S, 31.5)])
97
+ await _insert(session_factory, _run_task("lucidac0"), [(Q, 0), (P, 1)]) # still running
98
+ await _insert(session_factory, _compile_task(), [(Q, 0), (TaskStateEnum.CANCELLED, 3)]) # never started
99
+ async with session_factory() as session:
100
+ stats = await collect_task_statistics(session)
101
+ assert stats.wait == {
102
+ ("compile", ""): DurationStats(2, 6.0),
103
+ ("run", "lucidac0"): DurationStats(2, 31.0),
104
+ }
105
+ assert stats.work == {
106
+ ("compile", "", "succeeded"): DurationStats(1, 10.0),
107
+ ("compile", "", "failed"): DurationStats(1, 1.0),
108
+ ("run", "lucidac0", "succeeded"): DurationStats(1, 1.5),
109
+ }
@@ -0,0 +1,68 @@
1
+ """Tests for anabrid.cloud.version.build_info."""
2
+
3
+ from importlib import metadata
4
+
5
+ import pytest
6
+
7
+ from anabrid.cloud.version import BuildInfo, build_info, distribution_version
8
+
9
+
10
+ def _lookup(version: str):
11
+ def lookup(name: str) -> str:
12
+ assert name == "analogcloud-server"
13
+ return version
14
+
15
+ return lookup
16
+
17
+
18
+ def test_release_version_derives_tag_and_has_no_sha():
19
+ """A plain X.Y.Z version is a release: tag is vX.Y.Z, sha unknown."""
20
+ info = build_info("server", env={}, version_lookup=_lookup("0.2.0"))
21
+ assert info == BuildInfo("server", "0.2.0", None, "v0.2.0", None)
22
+
23
+
24
+ def test_dev_version_parses_sha_and_has_no_tag():
25
+ """The +g<sha> local segment of a dev version yields the short sha."""
26
+ info = build_info("server", env={}, version_lookup=_lookup("0.2.1.dev17+g1234abcd"))
27
+ assert info.git_sha == "1234abcd"
28
+ assert info.git_tag is None
29
+
30
+
31
+ def test_environment_overrides_win():
32
+ """Explicit ANALOGCLOUD_GIT_* / ENVIRONMENT values take precedence."""
33
+ env = {
34
+ "ANALOGCLOUD_GIT_SHA": "deadbeef",
35
+ "ANALOGCLOUD_GIT_TAG": "v9.9.9",
36
+ "ANALOGCLOUD_ENVIRONMENT": "prod",
37
+ }
38
+ info = build_info("server", env=env, version_lookup=_lookup("0.2.1.dev17+g1234abcd"))
39
+ assert (info.git_sha, info.git_tag, info.environment) == ("deadbeef", "v9.9.9", "prod")
40
+
41
+
42
+ def test_empty_environment_values_count_as_absent():
43
+ """Empty strings (unset Docker build args) do not override derived values."""
44
+ env = {"ANALOGCLOUD_GIT_SHA": "", "ANALOGCLOUD_GIT_TAG": "", "ANALOGCLOUD_ENVIRONMENT": ""}
45
+ info = build_info("server", env=env, version_lookup=_lookup("0.2.0"))
46
+ assert info == BuildInfo("server", "0.2.0", None, "v0.2.0", None)
47
+
48
+
49
+ def test_unknown_distribution():
50
+ """A missing distribution reports version 'unknown' instead of raising."""
51
+
52
+ def missing(name: str) -> str:
53
+ raise metadata.PackageNotFoundError(name)
54
+
55
+ info = build_info("server", env={}, version_lookup=missing)
56
+ assert info.version == "unknown"
57
+ assert distribution_version("nope", version_lookup=missing) is None
58
+
59
+
60
+ def test_as_dict_roundtrip():
61
+ """as_dict exposes exactly the dataclass fields."""
62
+ info = build_info("server", env={}, version_lookup=_lookup("0.2.0"))
63
+ assert set(info.as_dict()) == {"component", "version", "git_sha", "git_tag", "environment"}
64
+
65
+
66
+ def test_real_distribution_is_installed():
67
+ """The common package itself is resolvable through importlib.metadata."""
68
+ assert build_info("common", env={}).version != "unknown"