Compare commits

..
21 changed files with 2571 additions and 14 deletions
+56 -13
View File
@@ -21,6 +21,25 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Validate release tag and notes
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
if [ -z "${VERSION}" ]; then
echo "::error::could not determine the project version"
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
EXPECTED_TAG="v${VERSION}"
ACTUAL_TAG="${GITHUB_REF#refs/tags/}"
if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then
echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}"
exit 1
fi
fi
python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -144,20 +163,44 @@ jobs:
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
# Check if release already exists (idempotent re-runs)
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
if [ "$EXISTING" = "200" ]; then
echo "Release v${VERSION} already exists, skipping creation"
else
curl --fail -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
RELEASE_BODY="${RUNNER_TEMP}/release-body.md"
if [ ! -s "${RELEASE_BODY}" ]; then
echo "::error::validated release body is missing or empty"
exit 1
fi
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true)
case "${EXISTING}" in
200)
echo "Release v${VERSION} exists; resynchronizing its notes"
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
;;
404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}")"
;;
*)
echo "::error::release lookup failed with HTTP ${EXISTING}"
exit 1
;;
esac
METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")"
RELEASE_PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")"
PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")"
curl --fail --silent --show-error -X "${METHOD}" \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${RELEASE_PATH}"
- name: Attach artifacts to release
env:
+18
View File
@@ -0,0 +1,18 @@
# Changelog
<!--
Maintainers add one user-facing entry to Unreleased with each change. A release
commit bumps pyproject.toml, renames Unreleased to that bare-semver version and
an ISO date, then restores an empty Unreleased section; tag that commit. Do not
backfill releases from before this changelog.
-->
## [Unreleased]
## [0.3.4] - 2026-09-10
### Added
- Add Fenris identity and a polkit authentication notice to the dashboard.
- Clarify monitoring continuity, deliberate pauses, and quitting in the dashboard and status output.
- Add per-release notes with installation, verification, and rollback guidance.
+2
View File
@@ -197,6 +197,8 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
## Retired menu options
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
+20
View File
@@ -0,0 +1,20 @@
## Install
Install Fenris from its package channel after following the [package setup instructions](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#install-from-package-recommended):
```bash
sudo apt update && sudo apt install fenris # Debian / Ubuntu
sudo dnf install fenris # Fedora
sudo zypper install fenris # openSUSE Tumbleweed
```
## Verify downloads
```bash
gpg --output SHA256SUMS --decrypt SHA256SUMS.asc
sha256sum -c SHA256SUMS
```
## Rollback
Installing an older package over a newer observation store is unsupported. Restore the observation-store snapshot, then install the earlier Release; see the [upgrade and rollback guidance](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#upgrade).
+45
View File
@@ -0,0 +1,45 @@
# PROTOTYPE — graph encoding & hourly drill-down (throwaway)
Answers wayfinder ticket **Choose daily graph encoding and hourly drill-down**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
Which graph encoding best communicates hourly writes and a day's usage
without misleading: daily bars vs one candle per day vs rolling hourly strip,
with range -> selected day -> hourly detail -> back navigation.
## Run
./run # or: python3 tui_prototype.py
## Variants (switch with [ and ])
- **A — Daily bars**: one bar per local display day (bytes written), drill
into 24 hourly bars with Enter / click.
- **B — Daily candles**: one candle per day derived from hourly values
(open = first evidenced hour, close = last, high/low = max/min hourly).
Same drill-down.
- **C — Rolling hourly strip**: continuous last-72-hours columns with day
separators; click an hour for its readout (no day level).
## Data states encoded (synthetic)
measured zero (0 B) · gap / no evidence · unallocated usage (measured,
attribution unknown) · partial day ("so far") · deliberate-disable pause band ·
day before monitoring began.
Gaps never render as zero. Unallocated is a separate visual segment, never
spread into hours.
## Framework facts verified
Textual 8.2.8 (installed): no BarChart widget (roadmap-only), Sparkline is
non-interactive. Bars/candles here are a custom Static renderable; mouse
clicks map via widget-local x. No new dependencies.
## Screenshots
screenshots/ holds SVG exports at 80x24 and 140x40 per variant.
Regenerate with: python3 capture.py
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# PROTOTYPE helper — regenerate screenshots/*.svg via headless Textual run.
import asyncio
from tui_prototype import GraphPrototype
async def shot(app, size, presses, path):
async with app.run_test(size=size) as pilot:
for p in presses:
await pilot.press(p)
svg = app.export_screenshot()
with open(path, "w") as fh:
fh.write(svg)
print("wrote", path)
async def main():
for w, h in ((80, 24), (140, 40)):
for v in "ABC":
presses = ["]"] * "ABC".index(v)
await shot(GraphPrototype(), (w, h), presses,
"screenshots/v%s_%dx%d_range.svg" % (v, w, h))
# drill-down detail (variant A) and a mid-range selection (variant B)
await shot(GraphPrototype(), (80, 24), ["left", "enter"],
"screenshots/vA_80x24_hourly-detail.svg")
await shot(GraphPrototype(), (140, 40), ["]", "left", "left"],
"screenshots/vB_140x40_selected-gap-day.svg")
if __name__ == "__main__":
asyncio.run(main())
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# PROTOTYPE launcher
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
# PROTOTYPE — throwaway. Wayfinder ticket "Choose daily graph encoding and
# hourly drill-down" on map "Fenris TUI polish and hourly history".
# Synthetic data only. Not production code; no tests by design.
#
# Variants (switch with [ / ]):
# A daily bars (day -> Enter -> hourly bars -> Esc back)
# B daily candles (OHLC derived from hourly values, same drill-down)
# C rolling 72-hour strip with day separators (no day level)
#
# Encoding contract under test (accepted hourly-history decision):
# measured zero = 0 B, distinct from gap (no evidence), distinct from
# unallocated usage (measured total, hour attribution unknown).
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Static
# ---------------------------------------------------------------------------
# Synthetic evidence
# ---------------------------------------------------------------------------
@dataclass
class Hour:
gb: float = 0.0 # measured bytes written; 0.0 == measured zero
coverage: float = 1.0 # share of hour classified known
@dataclass
class Day:
hours: list = field(default_factory=list) # list[Hour | None]; None = gap
unallocated: float = 0.0 # GB measured, attribution unknown
elapsed_h: float = 24.0 # elapsed span (partial < 24)
note: str = ""
pause: str = "" # deliberate-disable band
@property
def evidenced(self):
return [h for h in self.hours if h is not None]
@property
def allocated_gb(self):
return sum(h.gb for h in self.evidenced)
@property
def gap_hours(self):
return sum(1 for h in self.hours if h is None)
def _profile(mult=1.0, zero=False):
shape = [0.3, 0.2, 0.1, 0.1, 0.2, 0.5, 1.4, 2.6, 3.1, 2.2,
1.8, 2.0, 2.4, 3.0, 3.4, 2.8, 2.1, 1.9, 2.3, 1.6,
1.1, 0.8, 0.5, 0.4]
return [Hour(0.0 if zero else round(v * mult, 2), 1.0) for v in shape]
def synth_days(now):
"""14 local display days ending today (partial). Every ticket state present."""
days = []
days.append(Day([None] * 24, note="no evidence — before monitoring began"))
days.append(Day(_profile(1.0)))
g = _profile(0.9)
for i in range(10, 16):
g[i] = None
days.append(Day(g, note="gap 10:00–16:00 (no evidence, not zero)"))
days.append(Day(_profile(0.0), note="measured zero day"))
days.append(Day(_profile(1.2), unallocated=8.5,
note="8.5 GB unallocated (interval attribution unknown)"))
days.append(Day(_profile(4.0), note="heavy write day"))
days.append(Day(_profile(0.7),
pause="paused 09:00–11:00 (deliberate disable — excluded)"))
days.append(Day(_profile(1.1)))
days.append(Day(_profile(0.9)))
m = _profile(1.0)
m[3] = Hour(0.0, 1.0)
m[17] = None
days.append(Day(m, note="zero hour 03:00, gap hour 17:00"))
days.append(Day(_profile(1.3), unallocated=1.9))
days.append(Day(_profile(1.0)))
days.append(Day(_profile(0.8)))
days.append(Day(_profile(1.5)[:14], elapsed_h=14,
note="today — 14 h elapsed, so far"))
assert len(days) == 14
return days
# ---------------------------------------------------------------------------
# Rendering (Rich markup into Static)
# ---------------------------------------------------------------------------
AMBER = "#e0a458" # default-preset graph colour (approved input)
UNALLOC = "#8a7f70" # measured, attribution unknown
GAP = "#5f6b73"
ZERO = "#9bb0bf"
SELECT = "#f2d5a0"
FILL = "█"
EIGHTHS = " ▁▂▃▄▅▆▇█"
def _fmt_gb(v):
if v >= 100:
return "%d GB" % v
return "%s GB" % ("%.1f" % v if v < 10 else "%.0f" % v)
def render_days_bars(days, height, sel):
"""Variant A: one bar per day. Unallocated stacked on top, visually separate."""
n = len(days)
mx = max((d.allocated_gb + d.unallocated) for d in days) or 1.0
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, d in enumerate(days):
if not d.evidenced and not d.unallocated:
for r in range(height):
grid[r][i], style[r][i] = "·", GAP
continue
rows = []
ua_cells = d.unallocated / mx * height
for _ in range(int(ua_cells + 1e-9)):
rows.append(("▒", UNALLOC))
if ua_cells - int(ua_cells) > 0.12:
rows.append((EIGHTHS[int((ua_cells % 1) * 8)], UNALLOC))
cells = (d.allocated_gb + d.unallocated) / mx * height
for _ in range(int(cells + 1e-9)):
rows.append((FILL, AMBER))
if cells - int(cells) > 0.12:
rows.append((EIGHTHS[int((cells % 1) * 8)], AMBER))
if not rows:
rows = [("·", ZERO)]
rr = height - 1
for ch, st in rows:
if rr < 0:
break
grid[rr][i], style[rr][i] = ch, st
rr -= 1
if d.elapsed_h < 24:
rr = height - len(rows)
if 0 <= rr < height:
grid[rr][i], style[rr][i] = "┄", SELECT
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
parts.append(" ")
lines.append("".join(parts).rstrip())
marks = [" "] * (2 * n)
marks[2 * sel] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_days_candles(days, height, sel):
"""Variant B: one candle per day. open = first evidenced hour, close = last,
high/low = max/min hourly GB. Body solid when close >= open, hollow otherwise;
partial day renders dashed. 3-wide slot + 1 space."""
n = len(days)
stats = []
for d in days:
ev = [h.gb for h in d.evidenced]
stats.append(None if not ev else
dict(open=ev[0], close=ev[-1], hi=max(ev), lo=min(ev)))
mx = max((s["hi"] for s in stats if s), default=1.0) or 1.0
def row(v):
return height - 1 - min(int(v / mx * (height - 1)), height - 1)
cols = [] # per day: list[height] of 3-char cell strings + styles
for i, s in enumerate(stats):
d = days[i]
if s is None:
cols.append([("╎╎╎", GAP)] * height)
continue
top, bot = row(s["hi"]), row(s["lo"])
body_hi, body_lo = row(max(s["open"], s["close"])), row(min(s["open"], s["close"]))
solid = s["close"] >= s["open"]
partial = d.elapsed_h < 24
body_fill = "▒" if partial else (FILL if solid else "░")
body_st = AMBER if (solid or partial) else GAP
col = []
for r in range(height):
if body_lo <= r <= body_hi:
col.append((body_fill * 3, body_st))
elif top <= r <= bot:
col.append((" │ ", AMBER if solid else GAP))
else:
col.append((" ", None))
cols.append(col)
lines = [(" " * (4 * sel)) + "▼"]
for r in range(height):
parts = []
for i in range(n):
cells, st = cols[i][r]
parts.append(cells if st is None else "[%s]%s[/]" % (st, cells))
parts.append(" ")
lines.append("".join(parts).rstrip())
return lines
def render_hours_bars(day, height, sel):
"""Day detail: one bar per hour. Gaps never zero."""
n = len(day.hours)
mx = max([h.gb for h in day.evidenced] + [day.unallocated, 1e-9])
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, h in enumerate(day.hours):
if h is None:
grid[height - 1][i], style[height - 1][i] = "░", GAP
continue
if h.gb == 0:
grid[height - 1][i], style[height - 1][i] = "·", ZERO
continue
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
grid[rr][i], style[rr][i] = FILL, AMBER
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
grid[rr][i], style[rr][i] = EIGHTHS[int((cells % 1) * 8)], AMBER
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = [" "] * n
marks[sel % n] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_strip(days, height, sel):
"""Variant C: rolling hourly strip, last 72 h, day separators."""
flat = [] # (day_idx, hour_idx, Hour|None|'future')
for di, d in enumerate(days[-4:]):
real = len(days) - 4 + di
for hi in range(24):
h = d.hours[hi] if hi < len(d.hours) else "future"
flat.append((real, hi, h))
flat = flat[-72:]
mx = max([f[2].gb for f in flat if f[2] not in (None, "future")] + [1e-9])
cols = []
for _, _, h in flat:
col = [(" ", None)] * height
if h == "future":
col[height - 1] = ("·", None)
elif h is None:
col[height - 1] = ("░", GAP)
elif h.gb == 0:
col[height - 1] = ("·", ZERO)
else:
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
col[rr] = (FILL, AMBER)
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
col[rr] = (EIGHTHS[int((cells % 1) * 8)], AMBER)
cols.append(col)
lines = []
for r in range(height):
parts = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
parts.append("[%s]│[/]" % SELECT)
prev = f[0]
ch, st = cols[i][r]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
marks.append(" ")
prev = f[0]
marks.append("▼" if i == sel else " ")
lines.insert(0, "".join(marks).rstrip())
return lines, flat
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
VARIANT_NAMES = {"A": "Daily bars", "B": "Daily candles", "C": "Rolling 72 h strip"}
GH = 10 # graph rows
class GraphPrototype(App):
TITLE = "PROTOTYPE — usage-history graph encoding"
CSS = """
#col { height: 100%; }
#hdr { height: 2; color: $text-muted; }
#graph { height: auto; padding: 0 1; }
#readout { height: auto; padding: 0 1; }
#legend { height: 3; padding: 0 1; color: $text-muted; }
#footer { dock: bottom; height: 2; background: $panel; padding: 0 1; }
"""
BINDINGS = [
Binding("[", "prev_variant", "variant ←"),
Binding("]", "next_variant", "variant →"),
Binding("left", "left", "←"),
Binding("right", "right", "→"),
Binding("enter", "drill", "open"),
Binding("escape,backspace", "back", "back"),
Binding("q", "quit", "quit"),
]
def __init__(self):
super().__init__()
self.now = datetime.now().astimezone()
self.days = synth_days(self.now)
self.variant = "A"
self.view = "days" # days | hours (A/B); strip (C)
self.sel_day = len(self.days) - 1
self.sel_hour = 13
self.sel_strip = 71
def day_label(self, i):
d = (self.now - timedelta(days=len(self.days) - 1 - i)).date()
return d, d.strftime("%a %d %b")
def tz_label(self):
off = self.now.strftime("%z")
return "Local · UTC%s · %s" % (off[:3] + ":" + off[3:], time.tzname[0])
def day_readout(self, d, i):
_, lab = self.day_label(i)
if not d.evidenced and not d.unallocated:
return "%s — no evidence (not zero): %s" % (lab, d.note or "")
bits = ["%s — %s written" % (lab, _fmt_gb(d.allocated_gb))]
span = 24 if d.elapsed_h == 24 else len(d.hours)
bits.append("%d/%d h evidenced" % (span - d.gap_hours, span))
if d.unallocated:
bits.append("+ %s unallocated" % _fmt_gb(d.unallocated))
if d.elapsed_h < 24:
bits.append("partial · %d h elapsed · so far" % int(d.elapsed_h))
cov = sum(h.coverage for h in d.evidenced) / max(len(d.hours), 1)
bits.append("coverage %.0f%%" % (100 * cov))
txt = " · ".join(bits)
if d.note:
txt += " — %s" % d.note
if d.pause:
txt += " — %s" % d.pause
return txt
def compose(self) -> ComposeResult:
with Vertical(id="col"):
yield Static("", id="hdr")
yield Static("", id="graph")
yield Static("", id="readout")
yield Static("", id="legend")
yield Static("", id="footer")
def on_mount(self) -> None:
self.refresh_all()
def refresh_all(self):
self.query_one("#hdr", Static).update(
"🐺 Fenris · usage history · %s · writes per hour" % self.tz_label())
g = self.query_one("#graph", Static)
ro = self.query_one("#readout", Static)
if self.view == "hours":
d = self.days[self.sel_day]
lines = render_hours_bars(d, GH, self.sel_hour)
lines.append("".join(("%02d" % i) if i % 3 == 0 else " " for i in range(len(d.hours))))
lines.append(" midnight → 23:00 local")
g.update("\n".join(lines))
h = d.hours[self.sel_hour]
if h is None:
dtxt = "hour %02d:00 — no evidence (not zero)" % self.sel_hour
elif h.gb == 0:
dtxt = "hour %02d:00 — 0 B measured" % self.sel_hour
else:
dtxt = "hour %02d:00 — %s written · coverage %.0f%%" % (
self.sel_hour, _fmt_gb(h.gb), 100 * h.coverage)
_, lab = self.day_label(self.sel_day)
extra = ""
if d.unallocated:
extra = " · %s unallocated (shown separately, never spread into hours)" % _fmt_gb(d.unallocated)
ro.update("%s ▸ %s%s" % (lab, dtxt, extra))
elif self.variant in ("A", "B"):
lines = (render_days_bars(self.days, GH, self.sel_day) if self.variant == "A"
else render_days_candles(self.days, GH, self.sel_day))
_, last = self.day_label(len(self.days) - 1)
lines.append(" " + " ".join(
self.day_label(i)[1].split()[1] for i in range(len(self.days))))
g.update("\n".join(lines))
ro.update(self.day_readout(self.days[self.sel_day], self.sel_day))
else:
lines, flat = render_strip(self.days, GH, self.sel_strip)
g.update("\n".join(lines))
txt = ""
if 0 <= self.sel_strip < len(flat):
di, hi, h = flat[self.sel_strip]
_, lab = self.day_label(di)
if h == "future":
txt = "future — not counted"
elif h is None:
txt = "no evidence (not zero)"
elif h.gb == 0:
txt = "0 B measured"
else:
txt = "%s written · coverage %.0f%%" % (_fmt_gb(h.gb), 100 * h.coverage)
ro.update("%s %02d:00 — %s" % (lab, hi, txt))
self.query_one("#legend", Static).update(
"legend: [%(a)s]█ allocated[/] [%(u)s]▒ unallocated (attribution unknown)[/] "
"[%(g)s]░ gap · no evidence — never zero[/] [%(z)s]· measured 0 B[/] "
"[%(s)s]┄ partial day (so far)[/]" % dict(a=AMBER, u=UNALLOC, g=GAP, z=ZERO, s=SELECT))
v = "%s: %s" % (self.variant, VARIANT_NAMES[self.variant])
mode = "hourly strip" if self.variant == "C" else (
"day detail" if self.view == "hours" else "range view")
self.query_one("#footer", Static).update(
"PROTOTYPE ▸ %s ▸ %s [ / ] variant · ←/→ select · Enter open · Esc back · click" % (v, mode))
def action_prev_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) - 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_next_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) + 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_left(self):
if self.view == "hours":
self.sel_hour = max(0, self.sel_hour - 1)
elif self.variant == "C":
self.sel_strip = max(0, self.sel_strip - 1)
else:
self.sel_day = max(0, self.sel_day - 1)
self.refresh_all()
def action_right(self):
if self.view == "hours":
self.sel_hour = min(23, self.sel_hour + 1)
elif self.variant == "C":
self.sel_strip = min(71, self.sel_strip + 1)
else:
self.sel_day = min(len(self.days) - 1, self.sel_day + 1)
self.refresh_all()
def action_drill(self):
if self.variant in ("A", "B") and self.view == "days":
self.view = "hours"
self.sel_hour = 13
self.refresh_all()
def action_back(self):
if self.view == "hours":
self.view = "days"
self.refresh_all()
def on_click(self, event: events.Click) -> None:
w = event.widget
if w is None or w.id != "graph":
return
x = event.x
if self.view == "hours":
self.sel_hour = max(0, min(23, x))
elif self.variant == "C":
self.sel_strip = max(0, min(71, x))
else:
self.sel_day = max(0, min(len(self.days) - 1, x // 2))
self.refresh_all()
if __name__ == "__main__":
GraphPrototype().run()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.3"
version = "0.3.4"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Extract one validated Keep a Changelog version section."""
from __future__ import annotations
import argparse
from datetime import date
from pathlib import Path
import re
import sys
class ChangelogError(ValueError):
"""A release cannot safely use the supplied changelog."""
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
_VERSION_HEADING = re.compile(
rf"^## \[(?P<version>{_SEMVER})\] - (?P<date>.+)$", re.MULTILINE
)
def extract_version_section(changelog: str, version: str) -> str:
"""Return *version*'s changelog section without altering its bytes.
The section ends immediately before the next level-two heading. A release
cannot use an absent, empty, or malformed version section.
"""
if not re.fullmatch(_SEMVER, version):
raise ChangelogError(f"requested version is not bare semver: {version!r}")
heading = next(
(match for match in _VERSION_HEADING.finditer(changelog)
if match.group("version") == version),
None,
)
if heading is None:
if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE):
raise ChangelogError(f"version {version} has a malformed heading or date")
raise ChangelogError(f"version {version} is missing from the changelog")
heading_date = heading.group("date")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date):
raise ChangelogError(f"version {version} has a malformed release date")
try:
date.fromisoformat(heading_date)
except ValueError as error:
raise ChangelogError(f"version {version} has a malformed release date") from error
next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE)
section_end = heading.end() + next_heading.start() if next_heading else len(changelog)
section = changelog[heading.start():section_end]
if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE):
raise ChangelogError(f"version {version} has an empty changelog section")
return section
def extract_changelog(path: Path, version: str) -> str:
"""Read and extract a requested version from a changelog file."""
try:
return extract_version_section(path.read_text(encoding="utf-8"), version)
except OSError as error:
raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error
def assemble_release_body(section: str, footer: str) -> str:
"""Append standing guidance while preserving the extracted section verbatim."""
separator = "\n" if section.endswith("\n") else "\n\n"
return f"{section}{separator}{footer}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("changelog", type=Path)
parser.add_argument("version")
parser.add_argument(
"--footer",
type=Path,
help="append this standing release guidance after the extracted section",
)
args = parser.parse_args(argv)
try:
section = extract_changelog(args.changelog, args.version)
if args.footer:
try:
footer = args.footer.read_text(encoding="utf-8")
except OSError as error:
raise ChangelogError(
f"cannot read release footer {args.footer}: {error.strerror}"
) from error
section = assemble_release_body(section, footer)
sys.stdout.write(section)
except ChangelogError as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Describe the Gitea request that creates or resynchronizes a release."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
from typing import Any
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
class ReleaseRequestError(ValueError):
"""A release request could not be prepared safely."""
def build_release_request(
version: str, body: str, existing_release: dict[str, Any] | None
) -> dict[str, Any]:
"""Return the observable POST or PATCH request for a Gitea release."""
if not re.fullmatch(_SEMVER, version):
raise ReleaseRequestError(f"version is not bare semver: {version!r}")
if existing_release is None:
return {
"method": "POST",
"path": "/releases",
"payload": {"tag_name": f"v{version}", "name": f"v{version}", "body": body},
}
release_id = existing_release.get("id")
if not isinstance(release_id, int):
raise ReleaseRequestError("existing release does not contain an integer id")
return {
"method": "PATCH",
"path": f"/releases/{release_id}",
"payload": {"body": body},
}
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ReleaseRequestError(f"cannot read existing release {path}: {error}") from error
if not isinstance(value, dict):
raise ReleaseRequestError("existing release must be a JSON object")
return value
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--body-file", type=Path, required=True)
parser.add_argument("--existing-release", type=Path)
args = parser.parse_args(argv)
try:
body = args.body_file.read_text(encoding="utf-8")
existing = _read_json(args.existing_release) if args.existing_release else None
print(json.dumps(build_release_request(args.version, body, existing)))
except (OSError, ReleaseRequestError) as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+211
View File
@@ -0,0 +1,211 @@
"""Release-note changelog extraction tests (DC-6, DC-7)."""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py"
RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py"
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
def _extractor_module():
spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_extracts_the_requested_version_section_verbatim():
extractor = _extractor_module()
changelog = """# Changelog
## [Unreleased]
## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
## [1.3.0] - 2026-09-01
### Fixed
- Preserve the observation history during upgrades.
"""
expected = """## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
"""
assert extractor.extract_version_section(changelog, "1.4.0") == expected
def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited():
lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines()
unreleased = lines.index("## [Unreleased]")
version_headings = [
index for index, line in enumerate(lines)
if line.startswith("## [") and line != "## [Unreleased]"
]
first_version = version_headings[0] if version_headings else len(lines)
categories = [
line.removeprefix("### ")
for line in lines[unreleased + 1:first_version]
if line.startswith("### ")
]
assert unreleased < first_version
assert set(categories) <= {"Added", "Changed", "Fixed"}
def test_release_footer_verifies_the_clearsigned_checksum_asset():
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
encoding="utf-8"
)
assert "gpg --output SHA256SUMS --decrypt SHA256SUMS.asc" in footer
assert "sha256sum -c SHA256SUMS" in footer
@pytest.mark.parametrize(
("changelog", "expected_error"),
[
("# Changelog\n\n## [Unreleased]\n", "missing"),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n",
"empty",
),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n",
"malformed release date",
),
],
)
def test_fails_closed_for_missing_empty_or_malformed_sections(
changelog, expected_error
):
extractor = _extractor_module()
with pytest.raises(extractor.ChangelogError, match=expected_error):
extractor.extract_version_section(changelog, "1.4.0")
def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8")
result = subprocess.run(
[sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode != 0
assert result.stderr.startswith("::error::")
assert "missing" in result.stderr
def test_assembles_a_release_body_without_changing_the_section():
extractor = _extractor_module()
section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n"
footer = "## Install\n\nUse the package channel.\n"
assert extractor.assemble_release_body(section, footer) == (
section + "\n" + footer
)
def test_command_can_write_the_complete_release_body(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
"### Added\n\n- Show a release summary.\n",
encoding="utf-8",
)
footer = tmp_path / "footer.md"
footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
"--footer",
str(footer),
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert result.stdout == (
"## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n"
"## Install\n\nUse the package channel.\n"
)
def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
body = tmp_path / "release-body.md"
body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8")
create = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
],
capture_output=True,
text=True,
check=False,
)
existing = tmp_path / "existing-release.json"
existing.write_text('{"id": 17, "assets": []}', encoding="utf-8")
patch = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
"--existing-release",
str(existing),
],
capture_output=True,
text=True,
check=False,
)
assert create.returncode == patch.returncode == 0
assert json.loads(create.stdout) == {
"method": "POST",
"path": "/releases",
"payload": {
"tag_name": "v1.4.0",
"name": "v1.4.0",
"body": "## [1.4.0] - 2026-09-10\n",
},
}
assert json.loads(patch.stdout) == {
"method": "PATCH",
"path": "/releases/17",
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
}
+23
View File
@@ -324,6 +324,29 @@ class TestCIWorkflow:
assert "upload" in content.lower() or "publish" in content.lower(), \
"Workflow must include upload/publish step"
def test_workflow_validates_notes_before_publication(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/extract_changelog.py" in content, \
"Workflow must fail before publication if release notes cannot be extracted"
assert "--footer packaging/release-footer.md" in content, \
"Workflow must assemble the body from the standing release footer"
def test_workflow_resynchronizes_existing_release_bodies(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/release_request.py" in content, \
"Workflow must make the create-versus-update decision through the request seam"
assert '"${METHOD}"' in content, \
"Workflow must execute the helper-selected create-or-update request"
assert 'RELEASE_PATH="$(printf' in content and '\n PATH="$(printf' not in content, \
"Workflow must not overwrite the shell PATH while preparing the request URL"
def test_readme_points_consumers_to_release_notes():
readme = _read("README.md")
assert "Per-release notes live on the [releases page]" in readme
assert "standing install and verification instructions" in readme
# ---------------------------------------------------------------------------
# Tests — Makefile release targets