Skip to content

Commit a2db3dd

Browse files
committed
Feature: extend health endpoint metadata
- derive service name from pyproject.toml - add uptime starttime and uptime seconds - add .data total size and first-level directory sizes - add pytest coverage for the health response shape - 2026-03-15 23:53:00
1 parent f41b7a0 commit a2db3dd

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

CODING_AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Before introducing new types, validators, formats, or storage conventions:
4747
- When adding new behavior, include tests covering the change.
4848
- New classes must have pytest coverage at a minimum for IPC and system calls.
4949
- Use `SystemCall` (`src/pypnm/lib/system_call/`) for subprocess/system calls; do not call `subprocess.run` directly in app code.
50+
- Avoid `try`/`except` inside hot loops; Ruff PERF rules flag this often. Move exception handling into a helper or restructure the loop before handing back commit/save commands.
5051
- Avoid broad refactors unless explicitly requested.
5152
- Any changes to `deploy/docker/config/system.json` must also be made in `demo/settings/system.json`.
5253
- Keep `deploy/docker/config/system.json.template` aligned with `deploy/docker/config/system.json`.
@@ -117,6 +118,7 @@ Before introducing new types, validators, formats, or storage conventions:
117118
- Testing expectations:
118119
- Run at least: `python3 -m compileall src`, `ruff check src`, `ruff format --check .`, `pytest -q`.
119120
- After any code change, run `ruff check src` and `pytest -q`. If only Markdown changes are made, run `mkdocs build -s` instead.
121+
- Review new loops and exception paths for Ruff performance rules before finalizing; do not rely on the user to discover PERF issues during `git-save.sh`.
120122
- This is mandatory for every code update in this repo: do not finalize work without reporting `ruff check` and `pytest` results (or a clear blocker).
121123
- If an integration test is optional/gated (for example Postgres DSN), note skips explicitly in the summary.
122124
- Troubleshooting:

docs/kubernetes/kind-freelens.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,37 @@ kubectl port-forward --namespace "${NAMESPACE}" deploy/pypnm-api 8000:8000
3838
curl -i http://127.0.0.1:8000/health
3939
```
4040

41-
If the returned `version` is older than expected, verify the tag used in the deploy command and confirm the namespace matches the running deployment.
41+
Example response:
42+
43+
```json
44+
{
45+
"status": "ok",
46+
"service": {
47+
"name": "pypnm-docsis",
48+
"version": "1.4.2.0"
49+
},
50+
"uptime": {
51+
"starttime": 1773640097,
52+
"uptime": 1
53+
},
54+
"data": {
55+
"path": ".data",
56+
"size_bytes": 1761579619,
57+
"directories": {
58+
"json": 1728244816,
59+
"xlsx": 0,
60+
"pnm": 17349665,
61+
"csv": 2819493,
62+
"png": 2805111,
63+
"db": 3388387,
64+
"archive": 6968882,
65+
"msg_rsp": 3265
66+
}
67+
}
68+
}
69+
```
70+
71+
If the returned `service.version` is older than expected, verify the tag used in the deploy command and confirm the namespace matches the running deployment.
4272

4373
## Connect with FreeLens
4474

src/pypnm/api/main.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from __future__ import annotations
55

66
import pathlib
7+
import re
78
import sys
89
from collections.abc import Awaitable, Callable
10+
from time import monotonic, time
911

1012
from fastapi import FastAPI
1113
from fastapi.middleware.cors import CORSMiddleware
@@ -65,13 +67,82 @@
6567
)
6668

6769
_hard_muted_routes: list[APIRoute] = []
70+
API_START_MONOTONIC: float = monotonic()
71+
API_START_EPOCH: int = int(time())
6872

6973

7074
def _route_tag_set(route: APIRoute) -> set[str]:
7175
tags = route.tags or []
7276
return {str(tag).strip().lower() for tag in tags if str(tag).strip() != ""}
7377

7478

79+
def _read_project_name() -> str:
80+
"""Read the project name from pyproject.toml, falling back to a stable default."""
81+
pyproject_path = project_root.parent / "pyproject.toml"
82+
if not pyproject_path.is_file():
83+
return "pypnm-docsis"
84+
85+
pyproject_text = pyproject_path.read_text(encoding="utf-8")
86+
project_match = re.search(r"^\[project\]\s*$", pyproject_text, re.MULTILINE)
87+
if project_match is None:
88+
return "pypnm-docsis"
89+
90+
tail_text = pyproject_text[project_match.end() :]
91+
name_match = re.search(r'^\s*name\s*=\s*"([^"]+)"\s*$', tail_text, re.MULTILINE)
92+
if name_match is None:
93+
return "pypnm-docsis"
94+
95+
return name_match.group(1).strip()
96+
97+
98+
def _service_uptime_seconds() -> int:
99+
"""Return process uptime in whole seconds since API module initialization."""
100+
elapsed_seconds = int(monotonic() - API_START_MONOTONIC)
101+
return max(elapsed_seconds, 0)
102+
103+
104+
SERVICE_NAME: str = _read_project_name()
105+
106+
107+
def _data_root_path() -> pathlib.Path:
108+
"""Return the repository-local `.data` directory used for runtime artifacts."""
109+
return pathlib.Path(".data")
110+
111+
112+
def _folder_size_bytes(folder_path: pathlib.Path) -> int:
113+
"""Return recursive folder size in bytes, ignoring inaccessible entries."""
114+
if not folder_path.exists():
115+
return 0
116+
117+
total_bytes = 0
118+
for path in folder_path.rglob("*"):
119+
total_bytes += _file_size_bytes(path)
120+
return total_bytes
121+
122+
123+
def _file_size_bytes(path: pathlib.Path) -> int:
124+
"""Return file size in bytes, or zero for non-files and inaccessible paths."""
125+
try:
126+
if path.is_file():
127+
return path.stat().st_size
128+
except OSError:
129+
return 0
130+
return 0
131+
132+
133+
def _first_level_directory_sizes(folder_path: pathlib.Path) -> dict[str, int]:
134+
"""Return recursive sizes for each first-level directory under the given root."""
135+
if not folder_path.exists():
136+
return {}
137+
138+
sizes: dict[str, int] = {}
139+
for child in folder_path.iterdir():
140+
if not child.is_dir():
141+
continue
142+
sizes[child.name] = _folder_size_bytes(child)
143+
return sizes
144+
145+
75146
def _apply_muted_tag_policy(app_instance: FastAPI) -> None:
76147
_hard_muted_routes.clear()
77148
muted_tags = read_env_csv_set(ENV_MUTE_TAGS)
@@ -90,9 +161,26 @@ def _apply_muted_tag_policy(app_instance: FastAPI) -> None:
90161

91162

92163
@app.get("/health", tags=["health"])
93-
def health() -> dict[str, str]:
164+
def health() -> dict[str, str | dict[str, int]]:
94165
"""Lightweight health endpoint for probes."""
95-
return {"status": "ok", "version": __version__}
166+
uptime_seconds = _service_uptime_seconds()
167+
data_root = _data_root_path()
168+
return {
169+
"status": "ok",
170+
"service": {
171+
"name": SERVICE_NAME,
172+
"version": __version__,
173+
},
174+
"uptime": {
175+
"starttime": API_START_EPOCH,
176+
"uptime": uptime_seconds,
177+
},
178+
"data": {
179+
"path": str(data_root),
180+
"size_bytes": _folder_size_bytes(data_root),
181+
"directories": _first_level_directory_sizes(data_root),
182+
},
183+
}
96184

97185
app.add_middleware(GZipMiddleware, minimum_size=100_000)
98186
app.add_middleware(

tests/test_api_health.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026 Maurice Garcia
3+
4+
from __future__ import annotations
5+
6+
from pathlib import Path
7+
8+
from pypnm.api import main as api_main
9+
from pypnm.version import __version__
10+
11+
12+
def test_health_includes_uptime(monkeypatch) -> None:
13+
monkeypatch.setattr(api_main, "API_START_MONOTONIC", 100.0)
14+
monkeypatch.setattr(api_main, "API_START_EPOCH", 1_741_709_200)
15+
monkeypatch.setattr(api_main, "SERVICE_NAME", "pypnm-docsis")
16+
monkeypatch.setattr(api_main, "_data_root_path", lambda: Path(".data"))
17+
monkeypatch.setattr(api_main, "_folder_size_bytes", lambda _: 4096)
18+
monkeypatch.setattr(api_main, "_first_level_directory_sizes", lambda _: {"pnm": 1024, "json": 2048})
19+
monkeypatch.setattr(api_main, "monotonic", lambda: 165.9)
20+
21+
response = api_main.health()
22+
23+
assert response == {
24+
"status": "ok",
25+
"service": {
26+
"name": "pypnm-docsis",
27+
"version": __version__,
28+
},
29+
"uptime": {
30+
"starttime": 1_741_709_200,
31+
"uptime": 65,
32+
},
33+
"data": {
34+
"path": ".data",
35+
"size_bytes": 4096,
36+
"directories": {
37+
"pnm": 1024,
38+
"json": 2048,
39+
},
40+
},
41+
}

0 commit comments

Comments
 (0)