4 Commits
Author SHA1 Message Date
Michael SauerandClaude Opus 5 cb54dac066 build: uv.lock der Station mit einchecken
tools/mkimage/uv.lock ist getrackt, tools/station/uv.lock war es nicht -
ohne die Datei sind die Testlaeufe der Station nicht reproduzierbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:17:49 +02:00
Michael SauerandClaude Sonnet 5 456058956c fix: setup_pi.sh und deploy.sh nutzen /usr/bin/ssh und /usr/bin/scp
Auf dem Rechner des Nutzers liegt im PATH ein ssh, das sich anders
verhaelt als das System-ssh - die Skripte sollen sich darauf nicht
verlassen. setup_pi.sh und deploy.sh riefen ssh, scp und rsync bisher
ohne vollen Pfad auf.

Beide Skripte nutzen jetzt SSH_BIN/SCP_BIN mit Default /usr/bin/ssh bzw.
/usr/bin/scp, ueberschreibbar per NINLIGHT_SSH_BIN/NINLIGHT_SCP_BIN -
dasselbe Prinzip wie NINLIGHT_STATION_ROOT weiter unten in deploy.sh, das
Tests ohne echten Pi ermoeglicht. rsync bekommt "-e $SSH_BIN", damit auch
dessen Transport ueber /usr/bin/ssh laeuft. Die Beispielaufrufe in
setup_pi.sh's Abschlussmeldung und in der README bleiben bewusst bei
schlichtem "ssh" - dort geht es um das, was der Nutzer selbst eintippt.

test: tools/station/tests/test_deploy.sh auf den neuen Override
umgestellt (NINLIGHT_SSH_BIN zeigt auf den ssh-Stub) und um ein Szenario
ergaenzt, das den /usr/bin/ssh- bzw. /usr/bin/scp-Default in beiden
Skripten absichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:13:15 +02:00
Michael SauerandClaude Sonnet 5 3032178247 fix: ninlight-station status gibt Dienstzustand nur einmal aus, Exit 0
"systemctl is-active" schreibt den Zustand ("inactive", ...) auf stdout
und liefert dabei einen Rueckgabewert ungleich 0 - der "|| echo inaktiv"-
Zweig lief also zusaetzlich mit, und beide Zeilen landeten in der
Ausgabe. Ausserdem endete "status" ueber den letzten Befehl im
case-Zweig faktisch mit dessen Exit-Code, der 1 war, sobald noch nie
ausgeliefert wurde (kein /run/ninlight-station/status.json) - das machte
"status" in jedem Skript, das den Exit-Code abfragt, unbrauchbar.

Beide Stellen behoben: der Dienstzustand wird nur noch einmal ausgegeben,
und der Zweig endet mit explizitem "exit 0", sobald die Auskunft erteilt
werden konnte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:13:03 +02:00
Michael SauerandClaude Sonnet 5 4a8a5d658f fix: nl_mkimage wieder Python-3.8-tauglich machen
CMake ruft nl_mkimage.py bei jedem Firmware-Build ueber das plain
"python3" im Docker-Build-Container auf (Python 3.8.10), unabhaengig von
den Werkzeugen auf Mac/Pi, die Python >= 3.11 voraussetzen. Die zuletzt
hinzugekommene Annotation "expect_device: str | None" wird dort zur
Laufzeit ausgewertet und wirft "TypeError: unsupported operand type(s)
for |: 'type' and 'NoneType'", was jeden Firmware-Build abbrechen liess.

"from __future__ import annotations" (wie in tools/station/server.py)
sorgt dafuer, dass Annotationen nur noch als Text existieren und nie
ausgewertet werden - der Rest der Datei kam ohne weitere 3.8-Stolpersteine
aus.

test: zwei Regressionstests ergaenzt, die ohne installiertes Python 3.8
auskommen: einer prueft per AST, dass der future-Import direkt nach dem
Docstring steht, der andere laesst vermin (dev-Dependency) die
Mindestversion aus Syntax/Stdlib-Nutzung ableiten. vermins
"union-types"-Feature wird bewusst nicht aktiviert, weil es "str | None"
unabhaengig vom future-Import als 3.10-Anforderung meldet - das wuerde
ausgerechnet den korrekten Fix durchfallen lassen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 13:12:54 +02:00
9 changed files with 229 additions and 12 deletions
+12
View File
@@ -4,7 +4,19 @@
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
firmware hashes while writing the image to flash.
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
build, and that container has not been upgraded past 3.8. This is independent of the
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
in annotations stay text and are never evaluated - without it, that syntax raises
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
"""
from __future__ import annotations
import argparse
import hashlib
import sys
+1 -1
View File
@@ -4,4 +4,4 @@ version = "0.1.0"
requires-python = ">=3.11"
[dependency-groups]
dev = ["pytest>=8"]
dev = ["pytest>=8", "vermin>=1.6"]
+68
View File
@@ -208,3 +208,71 @@ def test_verify_with_build_arguments_is_an_error(tmp_path):
)
assert r.returncode != 0
assert "cannot be combined" in r.stderr
# --- Python 3.8 compatibility -------------------------------------------------------------
#
# CMake calls this script via plain `python3` inside the build container on every firmware
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
# git history around the "unsupported operand type(s) for |" break for what happens when it
# regresses.
#
# Two checks, because neither alone covers the failure mode we actually hit:
# 1. `from __future__ import annotations` must be the first statement after the module
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
# them get added later.
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
# accounting for `from __future__ import annotations` deferring evaluation - which would
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
# what actually guards against that pattern.
def test_future_annotations_import_is_present():
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
import ast
tree = ast.parse(SCRIPT.read_text())
body = tree.body
# Skip a leading module docstring, if any.
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
body = body[1:]
assert body, "nl_mkimage.py is unexpectedly empty"
first = body[0]
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
and any(alias.name == "annotations" for alias in first.names), (
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
)
def test_stays_python38_compatible():
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
version from syntax/stdlib usage instead of actually executing the file.
"""
vermin = __import__("vermin")
config = vermin.Config()
config.set_quiet(True)
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
# they need runtime support either. This is vermin's default, spelled out here so it's
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
config.set_eval_annotations(False)
source = SCRIPT.read_text()
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
assert py3_min is not None and py3_min <= (3, 8), (
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
"Python 3.8.10 in the Docker build container (see module docstring)"
)
+14 -1
View File
@@ -28,12 +28,16 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "vermin" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "vermin", specifier = ">=1.6" },
]
[[package]]
name = "packaging"
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "vermin"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
]
+8 -3
View File
@@ -9,6 +9,10 @@ shift || true
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
declare -a FILES=()
if [ $# -gt 0 ]; then
@@ -54,11 +58,12 @@ echo "== uebertragen =="
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
# ausgelieferte Verzeichnis.
rsync -a -c --delete "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
ssh "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
set -e
FULL_SYNC=$1
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
+11 -1
View File
@@ -91,7 +91,13 @@ off)
fi
;;
status)
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || echo inaktiv)"
# "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
# liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
# ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb
# am Ende explizit exit 0.
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
echo "Images :"
@@ -104,7 +110,11 @@ print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" el
PY
)"
done
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
[ -r /run/ninlight-station/status.json ] && { echo "Zuletzt ausgeliefert:"; sed 's/^/ /' /run/ninlight-station/status.json; }
exit 0
;;
*)
echo "Aufruf: ninlight-station on|off|status" >&2
+11 -6
View File
@@ -6,17 +6,22 @@
set -euo pipefail
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
HERE=$(cd "$(dirname "$0")" && pwd)
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt
# einen echten Pi laufen kann.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
echo "== Voraussetzungen auf $HOST pruefen =="
ssh "$HOST" 'set -e
"$SSH_BIN" "$HOST" 'set -e
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
echo " ok"'
echo "== Konfiguration =="
scp -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
ssh "$HOST" 'set -e
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
"$SSH_BIN" "$HOST" 'set -e
if [ -f /etc/ninlight-station.conf ]; then
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
else
@@ -26,8 +31,8 @@ ssh "$HOST" 'set -e
rm -f /tmp/ninlight-station.conf.example'
echo "== Server, CLI und Unit installieren =="
scp -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
ssh "$HOST" 'set -e
"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
"$SSH_BIN" "$HOST" 'set -e
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
@@ -49,7 +54,7 @@ echo "== NetworkManager-Profil =="
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
# Prozessliste.
ssh "$HOST" bash -s <<'REMOTE'
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
eval "$(sudo cat /etc/ninlight-station.conf)"
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection"
+25
View File
@@ -114,6 +114,7 @@ run_deploy() {
# in der Sandbox muessen sie fuer "install" eigens angelegt werden.
mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
PATH="$STUBBIN:$PATH" \
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
@@ -232,6 +233,7 @@ echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
set +e
OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \
PATH="$STUBBIN:$PATH" \
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
@@ -250,6 +252,29 @@ else
pass "'Dienst laeuft nicht' wird nicht faelschlich gemeldet"
fi
echo
echo "=== Szenario 6: deploy.sh/setup_pi.sh nutzen ohne Override /usr/bin/ssh (bzw. scp) ==="
# Die anderen Szenarien laufen mit NINLIGHT_SSH_BIN auf einen Stub umgebogen - das prueft die
# Logik, aber nicht mehr, WELCHEN ssh das Skript ohne Override tatsaechlich waehlt. Der Nutzer
# verlangt ausdruecklich /usr/bin/ssh und /usr/bin/scp statt einer Variante im PATH; das hier
# ist die Absicherung dagegen, dass der Default beim naechsten Umbau stillschweigend zurueck
# auf ein PATH-"ssh" rutscht.
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/deploy.sh"; then
pass "deploy.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
else
fail "deploy.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
fi
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/setup_pi.sh"; then
pass "setup_pi.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
else
fail "setup_pi.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
fi
if grep -q 'SCP_BIN=\${NINLIGHT_SCP_BIN:-/usr/bin/scp}' "$STATION_DIR/setup_pi.sh"; then
pass "setup_pi.sh: Default fuer SCP_BIN ist /usr/bin/scp"
else
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
+79
View File
@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ninlight-station"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]