fleet-sensor-baseline 0.2.2__tar.gz → 0.2.3__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 (53) hide show
  1. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/.githooks/commit-msg +10 -5
  2. fleet_sensor_baseline-0.2.3/.githooks/pre-commit +82 -0
  3. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/.github/workflows/checks.yml +48 -0
  4. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/NOTICE +1 -1
  5. fleet_sensor_baseline-0.2.2/README.md → fleet_sensor_baseline-0.2.3/PKG-INFO +36 -4
  6. fleet_sensor_baseline-0.2.2/PKG-INFO → fleet_sensor_baseline-0.2.3/README.md +17 -23
  7. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/pyproject.toml +11 -1
  8. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/__init__.py +1 -1
  9. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/cli.py +90 -4
  10. fleet_sensor_baseline-0.2.3/tests/test_closed_pipe.py +194 -0
  11. fleet_sensor_baseline-0.2.3/tests/test_commit_msg_check.py +145 -0
  12. fleet_sensor_baseline-0.2.3/tests/test_hygiene_check.py +239 -0
  13. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_seam.py +31 -2
  14. fleet_sensor_baseline-0.2.3/tools/commit_msg_check.py +145 -0
  15. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tools/hygiene_check.py +94 -8
  16. fleet_sensor_baseline-0.2.2/.githooks/pre-commit +0 -23
  17. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/.gitignore +0 -0
  18. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/CONTRIBUTING.md +0 -0
  19. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/LICENSE +0 -0
  20. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/SECURITY.md +0 -0
  21. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/docs/formats.md +0 -0
  22. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/docs/upstream-asks.md +0 -0
  23. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/baseline.py +0 -0
  24. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/collect/__init__.py +0 -0
  25. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/collect/backends/__init__.py +0 -0
  26. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/collect/backends/mock.py +0 -0
  27. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/collect/backends/subprocess_backend.py +0 -0
  28. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/collect/collector.py +0 -0
  29. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/compare.py +0 -0
  30. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/drift.py +0 -0
  31. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/exits.py +0 -0
  32. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/for_referee.py +0 -0
  33. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/formats.py +0 -0
  34. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/outliers.py +0 -0
  35. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/report.py +0 -0
  36. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/store.py +0 -0
  37. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/verdict.py +0 -0
  38. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/src/fleet_sensor_baseline/walk.py +0 -0
  39. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/conftest.py +0 -0
  40. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_baseline.py +0 -0
  41. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_boundary.py +0 -0
  42. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_collector.py +0 -0
  43. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_compare.py +0 -0
  44. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_etag_collect.py +0 -0
  45. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_exits.py +0 -0
  46. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_for_referee.py +0 -0
  47. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_formats.py +0 -0
  48. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_hygiene.py +0 -0
  49. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_readme.py +0 -0
  50. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_readme_counts.py +0 -0
  51. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_referee_floor.py +0 -0
  52. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_scenarios.py +0 -0
  53. {fleet_sensor_baseline-0.2.2 → fleet_sensor_baseline-0.2.3}/tests/test_store.py +0 -0
@@ -14,12 +14,17 @@
14
14
  # while the code did something else entirely. Nothing checked the claim, because it
15
15
  # was a claim in a comment.
16
16
  #
17
- # This runs the publication vocabulary over the message: must this text never be
18
- # published? It is the one surface that cannot be undone after a push.
17
+ # Two checkers now, because they answer two different questions:
18
+ # hygiene_check.py --message must this text never be published?
19
+ # commit_msg_check.py is this message shaped like the others?
19
20
  #
20
- # To commit past it knowingly: git commit --no-verify
21
- # Note that --no-verify also disables the pre-commit hygiene sweep.
21
+ # Publication first: it is the one that cannot be undone after a push.
22
+ #
23
+ # To commit past both knowingly: git commit --no-verify
24
+ # Note that --no-verify also disables the pre-commit hygiene sweep. That is why
25
+ # these refuse only on objective problems and merely note the heuristic ones.
22
26
  set -e
23
27
  ROOT="$(git rev-parse --show-toplevel)"
24
28
 
25
- exec python3 "$ROOT/tools/hygiene_check.py" --message "$1"
29
+ python3 "$ROOT/tools/hygiene_check.py" --message "$1"
30
+ exec python3 "$ROOT/tools/commit_msg_check.py" "$1"
@@ -0,0 +1,82 @@
1
+ #!/bin/sh
2
+ # Refuse a commit carrying something that should not be published, and one whose
3
+ # README no longer describes the suite.
4
+ #
5
+ # Activate once per clone: git config core.hooksPath .githooks
6
+ #
7
+ # Versioned deliberately. A hook that lives only in .git/hooks exists on one
8
+ # machine, is invisible in review, and is absent from every fresh clone -- which
9
+ # for a repository authored in public means the safety check is missing exactly
10
+ # when a new contributor needs it most.
11
+ #
12
+ # To commit past it knowingly: git commit --no-verify
13
+ # That is a real escape hatch and it should be rare enough to notice.
14
+ set -e
15
+ ROOT="$(git rev-parse --show-toplevel)"
16
+
17
+ # FIRST, because it decides whether anything below it can speak for this commit.
18
+ # Every other check here reads the WORKING TREE; the commit records the INDEX.
19
+ # When they differ, a green hook is green about files that are not being
20
+ # committed -- which is how this repository shipped a README test count that was
21
+ # six short, twice in one day, with this hook passing both times.
22
+ python3 "$ROOT/tools/hygiene_check.py" --staged-is-what-ships
23
+
24
+ python3 "$ROOT/tools/hygiene_check.py"
25
+
26
+ # The README's test count, checked where the drift is created rather than where it
27
+ # is discovered. Dependency-free and about 0.2s.
28
+ #
29
+ # The count it checks is taken from what git TRACKS, not from what the directory
30
+ # holds, and that is the whole reason it can run here at all. The published 289 was
31
+ # measured on a working tree carrying a test file that was never committed: true of
32
+ # that disk, false of this repository. A disk-derived count would make this hook
33
+ # red on any tree with an untracked test in it -- red every run, for a reason that
34
+ # is not a defect, which is how a check teaches people to pass --no-verify. That
35
+ # flag also switches off the hygiene sweep above, so a check nobody can satisfy
36
+ # costs more than the one it was added to provide.
37
+ #
38
+ # The identity this commit will carry.
39
+ #
40
+ # A commit's AUTHOR is neither its message nor a file in it, so no text scanner
41
+ # reaches it -- the hygiene rules ran over both surfaces and a personal address
42
+ # still reached 24 published commits, because a local `user.email` quietly beat
43
+ # the global one and nothing read it. Checked here, before the commit exists,
44
+ # because after it is pushed a force-push changes the SHAs and removes nothing:
45
+ # the old objects stay fetchable and the address stays published.
46
+ AUTHOR_EMAIL=$(git config user.email || true)
47
+ case "$AUTHOR_EMAIL" in
48
+ *@users.noreply.github.com|*@noreply.github.com) ;;
49
+ "")
50
+ echo "pre-commit: no user.email is set, so this commit would carry a guess." >&2
51
+ exit 1 ;;
52
+ *)
53
+ echo "pre-commit: user.email is a real address, and a commit publishes it." >&2
54
+ echo " Set the GitHub noreply address for this repository:" >&2
55
+ echo " git config user.email <you>@users.noreply.github.com" >&2
56
+ echo " A local setting beats the global one; that is how this" >&2
57
+ echo " reached 24 published commits before anyone looked." >&2
58
+ exit 1 ;;
59
+ esac
60
+
61
+ # A missing pytest fails rather than skips. A gate that converts its own red into a
62
+ # skip removes the failure and the evidence together.
63
+ if ! python3 -c "import pytest" 2>/dev/null; then
64
+ echo "pre-commit: pytest is not installed, so the README count check cannot run." >&2
65
+ echo " python3 -m pip install pytest (or commit with --no-verify)" >&2
66
+ exit 1
67
+ fi
68
+
69
+ # The README-count check, where a repository has one.
70
+ #
71
+ # This step used to name `tests/test_readme_commands.py` outright. That file is
72
+ # this repository's; the same hook copied into four others refused EVERY commit,
73
+ # because pytest exits non-zero when it collects nothing and the hook could not
74
+ # tell "no such test" from "the test failed". The hooks are meant to be one file
75
+ # across the repositories, so the step has to find its target by SHAPE rather
76
+ # than be told a name that is only true here.
77
+ README_TESTS=$(ls "$ROOT"/tests/test_readme*.py 2>/dev/null || true)
78
+ if [ -z "$README_TESTS" ]; then
79
+ exit 0
80
+ fi
81
+
82
+ exec python3 -m pytest $README_TESTS -q -p no:cacheprovider
@@ -113,6 +113,54 @@ jobs:
113
113
  - name: The publication vocabulary, over the tree
114
114
  run: python3 tools/hygiene_check.py --all
115
115
 
116
+ - name: Commit messages
117
+ # The commit-msg hook's backstop, and the reason this is not optional: a
118
+ # message is the one published surface no later commit can correct.
119
+ run: |
120
+ set -euo pipefail
121
+ BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
122
+ if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] \
123
+ || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
124
+ echo "no usable base ref; checking HEAD only"
125
+ LIST=$(git rev-parse HEAD)
126
+ else
127
+ LIST=$(git rev-list "$BASE..${{ github.sha }}")
128
+ echo "checking $(echo "$LIST" | grep -c . ) commit message(s) in $BASE..${{ github.sha }}"
129
+ fi
130
+ FAILED=0
131
+ for SHA in $LIST; do
132
+ git log -1 --format=%B "$SHA" > /tmp/msg.txt
133
+ echo "--- $SHA"
134
+ python3 tools/commit_msg_check.py /tmp/msg.txt || FAILED=1
135
+ done
136
+ exit $FAILED
137
+
138
+ - name: Authorship
139
+ # A commit's author is neither its message nor a file in it, so neither
140
+ # check above can see it. A local `user.email` quietly beat the global
141
+ # one and a personal address reached 24 published commits. The hook
142
+ # checks the setting before the commit; this checks the RESULT after it,
143
+ # which is the thing that actually gets published.
144
+ run: |
145
+ set -euo pipefail
146
+ BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
147
+ if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] \
148
+ || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
149
+ LIST=$(git rev-parse HEAD)
150
+ else
151
+ LIST=$(git rev-list "$BASE..${{ github.sha }}")
152
+ fi
153
+ FAILED=0
154
+ for SHA in $LIST; do
155
+ for WHO in $(git log -1 --format='%ae%n%ce' "$SHA"); do
156
+ case "$WHO" in
157
+ *@users.noreply.github.com|*@noreply.github.com) ;;
158
+ *) echo "::error::$SHA carries a real address in its authorship"; FAILED=1 ;;
159
+ esac
160
+ done
161
+ done
162
+ exit $FAILED
163
+
116
164
  - name: A mock rack, end to end
117
165
  # 0.3's exit criterion, stated as a command: a verdict produced from
118
166
  # nothing but BMC endpoints. It runs against fake machines, which is the
@@ -1,5 +1,5 @@
1
1
  fleet-sensor-baseline
2
- Copyright 2026 James Sheen
2
+ Copyright 2026 The Arbiter Project Authors
3
3
 
4
4
  Licensed under the Apache License, Version 2.0 (the "License"); you may not use
5
5
  this file except in compliance with the License. You may obtain a copy of the
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: fleet-sensor-baseline
3
+ Version: 0.2.3
4
+ Summary: Which units differ from their cohort, and what changed across time.
5
+ Project-URL: Homepage, https://github.com/james-sheen/fleet-sensor-baseline
6
+ Project-URL: Source, https://github.com/james-sheen/fleet-sensor-baseline
7
+ Project-URL: Issues, https://github.com/james-sheen/fleet-sensor-baseline/issues
8
+ Author: James Sheen
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ License-File: NOTICE
12
+ Keywords: baseline,bmc,drift,fleet,hardware,openbmc,redfish,sensors
13
+ Requires-Python: >=3.10
14
+ Provides-Extra: collect
15
+ Requires-Dist: bmc-sensor-audit<0.4,>=0.2.0; extra == 'collect'
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
1
20
  # fleet-sensor-baseline
2
21
 
3
22
  **Which units differ from their cohort, and what changed on this unit across time.**
@@ -6,9 +25,18 @@
6
25
  list of machines and the history of captures, and answers the two questions the
7
26
  referee cannot.
8
27
 
9
- **Released — 0.2.2**, tagged `v0.2.2`, Apache-2.0, on PyPI as
28
+ **Released — 0.2.3**, tagged `v0.2.3`, Apache-2.0, on PyPI as
10
29
  `fleet-sensor-baseline`.
11
30
 
31
+ **0.2.3 admits the referee's 0.3 line, and stops a closed pipe reading as a
32
+ finding.** The `collect` extra held `bmc-sensor-audit` below 0.3 for eighteen
33
+ days after 0.3.0, and nothing here needed it: two tests read what 0.2 kept
34
+ where. They read the referee as installed now, and the suite passes on
35
+ referees 0.2.7, 0.3.0 and 0.3.5 -- which also lets this extra sit beside
36
+ `odm-cert-generator`, whose releases from 0.2.2 require the 0.3 line. And every
37
+ subcommand, `--help` included, raised `BrokenPipeError` when its reader closed
38
+ the pipe, exiting 1, which this family reads as findings about a rack.
39
+
12
40
  **0.2.2 adds `compare`, the threshold audit across time.** `drift` reads records
13
41
  and never opens a walk, so a threshold edited on a sensor that stayed present was
14
42
  invisible to it — and invisible to `collect --etag-cache` too, which is why a
@@ -350,7 +378,7 @@ first would pass by finding nothing.
350
378
 
351
379
  | | count |
352
380
  |---|---|
353
- | tests collected | 363 |
381
+ | tests collected | 447 |
354
382
  | of those, requiring `bmc-sensor-audit` | 40 |
355
383
 
356
384
  **The predicate**: `pytest --collect-only` over the test files git tracks, and
@@ -377,8 +405,12 @@ a walk with the referee's own reader and asserts the fixture still matches.
377
405
 
378
406
  ## Upstream
379
407
 
380
- Pinned at `bmc-sensor-audit>=0.2.0,<0.3`, and the floor is **derived, not
381
- chosen**. It has moved three times, each time to consume something reported from
408
+ Pinned at `bmc-sensor-audit>=0.2.0,<0.4`. **The ceiling was `<0.3` until
409
+ 2026-09-26**, eighteen days into the referee's 0.3 line, and what held it there
410
+ was this repository's own tests reading where 0.2 kept things -- the prefix
411
+ parser, which moved to `presence-audit` at 0.3.0, and a ceiling typed into the
412
+ pin guard. Both now read the referee as installed, and the suite passes on 0.2.7,
413
+ 0.3.0 and 0.3.5. The floor is **derived, not chosen**. It has moved three times, each time to consume something reported from
382
414
  here:
383
415
 
384
416
  - `>=0.1.2` for `--password-env`, so a credential never crosses argv, and for
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: fleet-sensor-baseline
3
- Version: 0.2.2
4
- Summary: Which units differ from their cohort, and what changed across time.
5
- Project-URL: Homepage, https://github.com/james-sheen/fleet-sensor-baseline
6
- Project-URL: Source, https://github.com/james-sheen/fleet-sensor-baseline
7
- Project-URL: Issues, https://github.com/james-sheen/fleet-sensor-baseline/issues
8
- Author: James Sheen
9
- License-Expression: Apache-2.0
10
- License-File: LICENSE
11
- License-File: NOTICE
12
- Keywords: baseline,bmc,drift,fleet,hardware,openbmc,redfish,sensors
13
- Requires-Python: >=3.10
14
- Provides-Extra: collect
15
- Requires-Dist: bmc-sensor-audit<0.3,>=0.2.0; extra == 'collect'
16
- Provides-Extra: dev
17
- Requires-Dist: pytest>=7; extra == 'dev'
18
- Description-Content-Type: text/markdown
19
-
20
1
  # fleet-sensor-baseline
21
2
 
22
3
  **Which units differ from their cohort, and what changed on this unit across time.**
@@ -25,9 +6,18 @@ Description-Content-Type: text/markdown
25
6
  list of machines and the history of captures, and answers the two questions the
26
7
  referee cannot.
27
8
 
28
- **Released — 0.2.2**, tagged `v0.2.2`, Apache-2.0, on PyPI as
9
+ **Released — 0.2.3**, tagged `v0.2.3`, Apache-2.0, on PyPI as
29
10
  `fleet-sensor-baseline`.
30
11
 
12
+ **0.2.3 admits the referee's 0.3 line, and stops a closed pipe reading as a
13
+ finding.** The `collect` extra held `bmc-sensor-audit` below 0.3 for eighteen
14
+ days after 0.3.0, and nothing here needed it: two tests read what 0.2 kept
15
+ where. They read the referee as installed now, and the suite passes on
16
+ referees 0.2.7, 0.3.0 and 0.3.5 -- which also lets this extra sit beside
17
+ `odm-cert-generator`, whose releases from 0.2.2 require the 0.3 line. And every
18
+ subcommand, `--help` included, raised `BrokenPipeError` when its reader closed
19
+ the pipe, exiting 1, which this family reads as findings about a rack.
20
+
31
21
  **0.2.2 adds `compare`, the threshold audit across time.** `drift` reads records
32
22
  and never opens a walk, so a threshold edited on a sensor that stayed present was
33
23
  invisible to it — and invisible to `collect --etag-cache` too, which is why a
@@ -369,7 +359,7 @@ first would pass by finding nothing.
369
359
 
370
360
  | | count |
371
361
  |---|---|
372
- | tests collected | 363 |
362
+ | tests collected | 447 |
373
363
  | of those, requiring `bmc-sensor-audit` | 40 |
374
364
 
375
365
  **The predicate**: `pytest --collect-only` over the test files git tracks, and
@@ -396,8 +386,12 @@ a walk with the referee's own reader and asserts the fixture still matches.
396
386
 
397
387
  ## Upstream
398
388
 
399
- Pinned at `bmc-sensor-audit>=0.2.0,<0.3`, and the floor is **derived, not
400
- chosen**. It has moved three times, each time to consume something reported from
389
+ Pinned at `bmc-sensor-audit>=0.2.0,<0.4`. **The ceiling was `<0.3` until
390
+ 2026-09-26**, eighteen days into the referee's 0.3 line, and what held it there
391
+ was this repository's own tests reading where 0.2 kept things -- the prefix
392
+ parser, which moved to `presence-audit` at 0.3.0, and a ceiling typed into the
393
+ pin guard. Both now read the referee as installed, and the suite passes on 0.2.7,
394
+ 0.3.0 and 0.3.5. The floor is **derived, not chosen**. It has moved three times, each time to consume something reported from
401
395
  here:
402
396
 
403
397
  - `>=0.1.2` for `--password-env`, so a credential never crosses argv, and for
@@ -75,7 +75,17 @@ dependencies = []
75
75
  # caller's install line.** `compare` needs the referee too -- it hands two
76
76
  # stored walks to `regression` -- so this extra now gates two commands. Both
77
77
  # were verified against every release the range admits: 0.2.0, 0.2.1, 0.2.2.
78
- collect = ["bmc-sensor-audit>=0.2.0,<0.3"]
78
+ #
79
+ # **The ceiling rose to <0.4 on 2026-09-26**, eighteen days after the referee
80
+ # reached 0.3.0. Nothing here was wrong with the referee's 0.3 line: the only
81
+ # reds were two tests reading what 0.2 happened to keep where -- the prefix
82
+ # parser, which moved to `presence-audit` with the 0.3.0 split, and a ceiling
83
+ # typed into the pin guard. Both read the referee as it is now. Measured
84
+ # before the move: 446 passed on 0.2.7 and 8 failed on 0.3.5, all eight those
85
+ # two; after it, the suite passes on 0.2.7, 0.3.0 and 0.3.5. Meanwhile
86
+ # `odm-cert-generator` 0.2.2 already required the referee at >=0.3.0, so this
87
+ # extra could not be installed beside the certificate renderer at all.
88
+ collect = ["bmc-sensor-audit>=0.2.0,<0.4"]
79
89
  dev = ["pytest>=7"]
80
90
 
81
91
  [project.scripts]
@@ -15,6 +15,6 @@ from __future__ import annotations
15
15
  # read this to decide whether the README may name a tag at all: an unreleased
16
16
  # tree that announces `v0.1.0` hands a reader a tag to check out that does not
17
17
  # exist. Bump this at step 1 of a release, not at step 9.
18
- __version__ = "0.2.2"
18
+ __version__ = "0.2.3"
19
19
 
20
20
  __all__ = ["__version__"]
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import json
15
+ import os
15
16
  import sys
16
17
  from pathlib import Path
17
18
  from typing import Any, Iterable
@@ -714,13 +715,98 @@ def build_parser() -> argparse.ArgumentParser:
714
715
  return parser
715
716
 
716
717
 
718
+ class _StdoutThatOutlivesItsReader:
719
+ """`sys.stdout`, for a program whose exit code is a claim about a fleet.
720
+
721
+ **A reader that stops reading has said something about itself, not about
722
+ the rack.** `fleet-sensor-baseline baseline ... | head` is an ordinary
723
+ thing to do, and before this every subcommand died of it -- including
724
+ `--help`, which is the likeliest thing anyone pipes. The failure arrived
725
+ two ways, and neither is in the vocabulary `exits.py` defines: a report
726
+ long enough to fill the pipe buffer raised out of `print` and the
727
+ interpreter exited `1`, which this tool means as FINDINGS; a shorter one
728
+ survived to the shutdown flush, printed `Exception ignored` to stderr and
729
+ exited `120`, which is not a verdict at all.
730
+
731
+ Both replace a statement about the fleet with a statement about the
732
+ terminal. Absorbing rather than refusing is the point: `INCOMPLETE` says
733
+ the fleet could not be assessed, and a baseline whose reader walked away
734
+ was assessed perfectly well.
735
+
736
+ The sibling package has carried this since its own two occurrences. This
737
+ is the third in the family, and it shipped in 0.2.2.
738
+ """
739
+
740
+ def __init__(self, stream: Any) -> None:
741
+ self._stream = stream
742
+ self.reader_left = False
743
+
744
+ def _abandon(self) -> None:
745
+ """Point the descriptor at nowhere, then stop trying.
746
+
747
+ The interpreter flushes `stdout` again on its way out, on bytes this
748
+ stream may still hold. Without the redirect that second flush raises
749
+ where no `except` can reach it -- which is the `Exception ignored`
750
+ line, and the `120`.
751
+ """
752
+ self.reader_left = True
753
+ try:
754
+ fileno = self._stream.fileno()
755
+ except (AttributeError, ValueError, OSError):
756
+ return # captured by a harness rather than piped; nothing to point
757
+ try:
758
+ os.dup2(os.open(os.devnull, os.O_WRONLY), fileno)
759
+ except OSError:
760
+ pass
761
+
762
+ def write(self, text: str) -> int:
763
+ if self.reader_left:
764
+ return len(text)
765
+ try:
766
+ return self._stream.write(text)
767
+ except BrokenPipeError:
768
+ self._abandon()
769
+ return len(text)
770
+
771
+ def flush(self) -> None:
772
+ if self.reader_left:
773
+ return
774
+ try:
775
+ self._stream.flush()
776
+ except BrokenPipeError:
777
+ self._abandon()
778
+
779
+ def __getattr__(self, name: str) -> Any:
780
+ return getattr(self._stream, name)
781
+
782
+
717
783
  def main(argv: list[str] | None = None) -> int:
718
- args = build_parser().parse_args(argv)
784
+ # Installed before the parser runs, because `--help` prints through it too
785
+ # and argparse exits from inside `parse_args`.
786
+ stdout = _StdoutThatOutlivesItsReader(sys.stdout)
787
+ sys.stdout = stdout
719
788
  try:
720
- return args.func(args)
721
- except StoreError as exc:
722
- print(f"{args.command}: {exc}", file=sys.stderr)
789
+ args = build_parser().parse_args(argv)
790
+ try:
791
+ return args.func(args)
792
+ except StoreError as exc:
793
+ print(f"{args.command}: {exc}", file=sys.stderr)
794
+ return INCOMPLETE
795
+ except BrokenPipeError:
796
+ # A pipe that broke somewhere the wrapper does not cover is a failure
797
+ # to deliver, and this tool says so with the code that means it.
798
+ print("the output could not be written: the pipe closed",
799
+ file=sys.stderr)
723
800
  return INCOMPLETE
801
+ finally:
802
+ # **Flush through the wrapper, before handing the stream back.** A
803
+ # report short enough to sit in the buffer is not written until the
804
+ # interpreter flushes on its way out -- by which point this wrapper is
805
+ # gone and the failure lands where no `except` can reach it.
806
+ try:
807
+ stdout.flush()
808
+ finally:
809
+ sys.stdout = stdout._stream
724
810
 
725
811
 
726
812
  if __name__ == "__main__": # pragma: no cover
@@ -0,0 +1,194 @@
1
+ """`| head` is not a fault in the fleet.
2
+
3
+ **The exit code is the claim, and the claim is about a rack.** A reader that
4
+ stops reading has said something about itself, not about the machines, so the
5
+ code this tool returns must not move when somebody pipes a report into `head`.
6
+
7
+ Until this was guarded it moved two different ways, and 0.2.2 shipped with both.
8
+ A report long enough to fill the pipe buffer raised `BrokenPipeError` out of
9
+ `print`, escaped `main`, and left Python to exit `1` -- and `1` in this
10
+ vocabulary means FINDINGS, so a truncated report was indistinguishable from a
11
+ complete one to anything reading the code. A short report failed later instead,
12
+ at the interpreter's shutdown flush, printing `Exception ignored` and exiting
13
+ `120`, which `exits.normalise` reads as INCOMPLETE. Either way an aggregator
14
+ files a rack on the strength of where the operator's terminal stopped.
15
+
16
+ `--help` was in the second class, which is the part worth stating plainly: the
17
+ likeliest thing anybody pipes was the likeliest thing to fail.
18
+
19
+ The sibling package guards this and has since its own two occurrences. This is
20
+ the third in the family. The rule was written down after the second; writing it
21
+ down is not a mechanism, and a test is, which is why this file exists rather
22
+ than a line in a document.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import pytest
33
+
34
+ ROOT = Path(__file__).resolve().parents[1]
35
+ sys.path.insert(0, str(ROOT / "src"))
36
+
37
+ from conftest import record # noqa: E402
38
+
39
+ #: Enough one-line results to carry the rendered output comfortably past a 64KB
40
+ #: pipe buffer, so the failure lands in `print` rather than at the shutdown
41
+ #: flush. Both paths are exercised below; non-vacuity is asserted, not assumed.
42
+ WIDE = 1200
43
+
44
+
45
+ def _write_records(where: Path, count: int, *, valid: bool) -> list[str]:
46
+ paths = []
47
+ for index in range(count):
48
+ path = where / f"r{index:05d}.json"
49
+ if valid:
50
+ payload = record(f"rack-01/unit{index:05d}",
51
+ captured_at="2026-08-20T00:00:00Z",
52
+ digest="sha256:" + f"{index:064x}")
53
+ else:
54
+ payload = {"format": "fleet-sensor-baseline/fleet-record/1"}
55
+ path.write_text(json.dumps(payload), encoding="utf-8")
56
+ paths.append(str(path))
57
+ return paths
58
+
59
+
60
+ @pytest.fixture(scope="module")
61
+ def wide_clean(tmp_path_factory):
62
+ """A long report whose verdict is CLEAN, and the reason it must be.
63
+
64
+ **An unhandled `BrokenPipeError` makes Python exit `1`.** A fixture whose
65
+ honest verdict is also `1` cannot tell a preserved verdict from a crash --
66
+ the two agree by coincidence and the assertion passes against the unguarded
67
+ code. `validate` answers only `0` or `2`, so every fixture here is already
68
+ clear of the crash code; this one is pinned anyway, because the day
69
+ `validate` grows a FINDINGS path is the day these tests go quiet without
70
+ going red.
71
+ """
72
+ where = tmp_path_factory.mktemp("wide_clean")
73
+ return ["validate", *_write_records(where, WIDE, valid=True)]
74
+
75
+
76
+ @pytest.fixture(scope="module")
77
+ def wide_incomplete(tmp_path_factory):
78
+ """The same length, verdict INCOMPLETE.
79
+
80
+ Kept because `2` is the verdict an operator most often pipes into `head` --
81
+ the run that found something wrong is the run worth skimming.
82
+ """
83
+ where = tmp_path_factory.mktemp("wide_incomplete")
84
+ return ["validate", *_write_records(where, WIDE, valid=False)]
85
+
86
+
87
+ @pytest.fixture(scope="module")
88
+ def narrow(tmp_path_factory):
89
+ """A run whose report is short, and whose verdict is CLEAN.
90
+
91
+ Too little output to fail during `print`, so it reaches the shutdown flush
92
+ instead -- the path that produced `Exception ignored` and `120`.
93
+ """
94
+ where = tmp_path_factory.mktemp("narrow")
95
+ return ["validate", *_write_records(where, 2, valid=True)]
96
+
97
+
98
+ def _environment() -> dict:
99
+ return {"PYTHONPATH": str(ROOT / "src"), "PATH": "/usr/bin:/bin"}
100
+
101
+
102
+ def _unpiped(argv) -> subprocess.CompletedProcess:
103
+ return subprocess.run([sys.executable, "-m", "fleet_sensor_baseline.cli", *argv],
104
+ capture_output=True, text=True, env=_environment())
105
+
106
+
107
+ def _through_head(argv, lines: int) -> tuple[int, str]:
108
+ """`(writer exit code, writer stderr)` -- the writer's, never the pipe's.
109
+
110
+ A shell pipeline reports the LAST command's status, which here is `head`,
111
+ which is always `0`. Measuring that would make every assertion below
112
+ vacuously true; this project has three recorded instances of exactly that
113
+ mistake, so the writer is held open and asked directly.
114
+ """
115
+ writer = subprocess.Popen(
116
+ [sys.executable, "-m", "fleet_sensor_baseline.cli", *argv],
117
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
118
+ env=_environment())
119
+ reader = subprocess.Popen(["head", "-n", str(lines)], stdin=writer.stdout,
120
+ stdout=subprocess.DEVNULL)
121
+ writer.stdout.close()
122
+ reader.wait()
123
+ stderr = writer.stderr.read()
124
+ writer.stderr.close()
125
+ writer.wait()
126
+ return writer.returncode, stderr
127
+
128
+
129
+ class TestTheReportIsLongEnoughToReachTheDefect:
130
+ def test_the_wide_fixture_would_fill_a_pipe_buffer(self, wide_clean):
131
+ """Non-vacuity for everything below. A report that fits inside the 64KB
132
+ buffer never makes the writer notice the reader has gone, so a suite
133
+ built on a short report passes against the unguarded code."""
134
+ rendered = _unpiped(wide_clean)
135
+ assert len(rendered.stdout) > 65536, (
136
+ f"the report is {len(rendered.stdout)} bytes and cannot close a "
137
+ f"pipe before the writer finishes")
138
+
139
+ def test_the_incomplete_fixture_is_long_too(self, wide_incomplete):
140
+ assert len(_unpiped(wide_incomplete).stdout) > 65536
141
+
142
+ def test_no_fixture_shares_the_crash_code(self, wide_clean, wide_incomplete,
143
+ narrow):
144
+ """`1` is what an unhandled exception exits with. If any fixture ever
145
+ returns it, the assertions below agree with a crash by coincidence."""
146
+ for argv in (wide_clean, wide_incomplete, narrow):
147
+ assert _unpiped(argv).returncode != 1
148
+
149
+
150
+ class TestAClosedPipeDoesNotMoveTheVerdict:
151
+ def test_a_long_clean_report_keeps_its_exit_code(self, wide_clean):
152
+ """**The assertion the whole guard exists for.** Not *no traceback* --
153
+ a run that printed nothing and exited 120 would satisfy that."""
154
+ expected = _unpiped(wide_clean).returncode
155
+ assert expected == 0
156
+ code, _ = _through_head(wide_clean, 10)
157
+ assert code == expected, (
158
+ f"piping the report changed the verdict from {expected} to {code}")
159
+
160
+ def test_a_long_incomplete_report_keeps_its_exit_code(self, wide_incomplete):
161
+ expected = _unpiped(wide_incomplete).returncode
162
+ assert expected == 2
163
+ code, _ = _through_head(wide_incomplete, 10)
164
+ assert code == expected
165
+
166
+ def test_a_clean_run_stays_clean(self, narrow):
167
+ """The direction a refusal-shaped fix would break. Mapping a closed pipe
168
+ to INCOMPLETE would file a healthy rack as unwalked."""
169
+ assert _unpiped(narrow).returncode == 0
170
+ code, _ = _through_head(narrow, 1)
171
+ assert code == 0
172
+
173
+ def test_a_reader_that_leaves_immediately_is_also_clean(self, narrow):
174
+ """The shutdown-flush path, which is where `--help` failed."""
175
+ code, stderr = _through_head(narrow, 0)
176
+ assert code == 0, f"exit {code} for a clean run whose reader left"
177
+ assert "Exception ignored" not in stderr
178
+
179
+
180
+ class TestNothingIsPrintedAboutIt:
181
+ @pytest.mark.parametrize("lines", [0, 1, 10])
182
+ def test_no_traceback_reaches_the_operator(self, wide_clean, lines):
183
+ _, stderr = _through_head(wide_clean, lines)
184
+ assert stderr == "", stderr
185
+
186
+ @pytest.mark.parametrize("argv", ["--help", "validate --help",
187
+ "baseline --help", "collect --help"])
188
+ def test_help_survives_its_likeliest_reader(self, argv):
189
+ """`--help | head` is the commonest pipe there is, and it was broken on
190
+ every subcommand. Parametrised over four because argparse exits from
191
+ inside `parse_args`, before anything this program wrote could run."""
192
+ code, stderr = _through_head(argv.split(), 0)
193
+ assert stderr == "", stderr
194
+ assert code == 0