shuttlecheck 0.2.0__tar.gz → 0.3.0__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 (74) hide show
  1. {shuttlecheck-0.2.0/shuttlecheck.egg-info → shuttlecheck-0.3.0}/PKG-INFO +7 -6
  2. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/PYPI.md +5 -5
  3. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/README.md +26 -26
  4. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/pyproject.toml +8 -1
  5. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/__init__.py +4 -4
  6. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/cli.py +60 -4
  7. shuttlecheck-0.3.0/shuttlecheck/completeness.py +227 -0
  8. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/delivery.py +2 -2
  9. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/extensions.py +2 -2
  10. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/infer.py +2 -2
  11. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/jobs.py +44 -19
  12. shuttlecheck-0.3.0/shuttlecheck/mhl.py +454 -0
  13. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/patterns.py +3 -3
  14. shuttlecheck-0.3.0/shuttlecheck/receive.py +517 -0
  15. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/report.py +1 -1
  16. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/schedule.py +2 -2
  17. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/schema.json +15 -1
  18. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/sidecar.py +235 -7
  19. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/spec.py +134 -6
  20. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/store.py +27 -0
  21. shuttlecheck-0.3.0/shuttlecheck/support.py +465 -0
  22. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/ui.py +195 -15
  23. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/ui_assets/app.css +223 -22
  24. shuttlecheck-0.3.0/shuttlecheck/ui_assets/app.js +2361 -0
  25. shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/inter-LICENSE.txt +93 -0
  26. shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/inter.woff2 +0 -0
  27. shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/mono-LICENSE.txt +93 -0
  28. shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/mono.woff2 +0 -0
  29. shuttlecheck-0.3.0/shuttlecheck/updates.py +154 -0
  30. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/validate.py +49 -7
  31. shuttlecheck-0.3.0/shuttlecheck/version.py +1 -0
  32. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/walkthrough.py +4 -4
  33. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0/shuttlecheck.egg-info}/PKG-INFO +7 -6
  34. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/SOURCES.txt +17 -0
  35. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/requires.txt +1 -0
  36. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_cli.py +28 -1
  37. shuttlecheck-0.3.0/tests/test_completeness.py +361 -0
  38. shuttlecheck-0.3.0/tests/test_fonts.py +118 -0
  39. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_infer.py +1 -1
  40. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_jobs.py +30 -0
  41. shuttlecheck-0.3.0/tests/test_mhl.py +343 -0
  42. shuttlecheck-0.3.0/tests/test_min_version.py +134 -0
  43. shuttlecheck-0.3.0/tests/test_receive.py +371 -0
  44. shuttlecheck-0.3.0/tests/test_rename_token.py +161 -0
  45. shuttlecheck-0.3.0/tests/test_support.py +292 -0
  46. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_ui.py +12 -7
  47. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_ui_routes.py +125 -0
  48. shuttlecheck-0.3.0/tests/test_updates.py +263 -0
  49. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_validate.py +3 -3
  50. shuttlecheck-0.2.0/shuttlecheck/ui_assets/app.js +0 -934
  51. shuttlecheck-0.2.0/shuttlecheck/version.py +0 -1
  52. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/LICENSE +0 -0
  53. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/setup.cfg +0 -0
  54. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/auth.py +0 -0
  55. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/checks.py +0 -0
  56. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/originals.py +0 -0
  57. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/telemetry-v1.schema.json +0 -0
  58. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/telemetry.py +0 -0
  59. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck/ui_assets/app.html +0 -0
  60. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/dependency_links.txt +0 -0
  61. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/entry_points.txt +0 -0
  62. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/top_level.txt +0 -0
  63. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_auth.py +0 -0
  64. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_classify.py +0 -0
  65. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_delivery.py +0 -0
  66. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_groups.py +0 -0
  67. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_infer_keys.py +0 -0
  68. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_no_convention.py +0 -0
  69. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_originals.py +0 -0
  70. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_path_length.py +0 -0
  71. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_pick.py +0 -0
  72. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_store.py +0 -0
  73. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_telemetry.py +0 -0
  74. {shuttlecheck-0.2.0 → shuttlecheck-0.3.0}/tests/test_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shuttlecheck
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Check media filenames, folder structure and deliverables against a written convention. Read-only.
5
5
  Author-email: MVRK Systems <hello@mvrk.systems>
6
6
  License: Proprietary — free to use, no redistribution
@@ -26,6 +26,7 @@ Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: PyYAML>=6.0
28
28
  Requires-Dist: jsonschema>=4.20
29
+ Requires-Dist: xxhash>=3.0
29
30
  Requires-Dist: tzdata>=2024.1; sys_platform == "win32"
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -65,8 +66,8 @@ FIELDS RECOVERED
65
66
  {title} slug 85% opening-remarks, keynote
66
67
  ```
67
68
 
68
- It handles conventions that disagree with themselves — several accepted forms
69
- per field, optional elements, free-text titles containing the separator —
69
+ It handles conventions that disagree with themselves: several accepted forms
70
+ per field, optional elements, free-text titles containing the separator, all
70
71
  because real ones do.
71
72
 
72
73
  ## Then it tells you what breaks it
@@ -101,14 +102,14 @@ conflated, because a noisy report does not get read.
101
102
  | Unicode | Names that compare equal on one platform and not another |
102
103
 
103
104
  Media inspection uses `ffprobe` when it is on your PATH. When it is not, those
104
- checks are reported as **skipped** — never as passed. A silent skip is how a
105
+ checks are reported as **skipped**, never as passed. A silent skip is how a
105
106
  conformance tool starts lying.
106
107
 
107
108
  ## For live events, the running order does the work
108
109
 
109
110
  An event agenda is structured data that exists weeks before anyone turns a
110
- camera on. Joined against time-of-day timecode, every recording names itself —
111
- stage, session, speaker, sponsor — with no inference of any kind.
111
+ camera on. Joined against time-of-day timecode, every recording names itself,
112
+ with its stage, session, speaker and sponsor, and no inference of any kind.
112
113
 
113
114
  It will also tell you when a filename is well-formed and still wrong: `S007`
114
115
  recorded during `S011` is a valid shape carrying a false claim, and only the
@@ -32,8 +32,8 @@ FIELDS RECOVERED
32
32
  {title} slug 85% opening-remarks, keynote
33
33
  ```
34
34
 
35
- It handles conventions that disagree with themselves — several accepted forms
36
- per field, optional elements, free-text titles containing the separator —
35
+ It handles conventions that disagree with themselves: several accepted forms
36
+ per field, optional elements, free-text titles containing the separator, all
37
37
  because real ones do.
38
38
 
39
39
  ## Then it tells you what breaks it
@@ -68,14 +68,14 @@ conflated, because a noisy report does not get read.
68
68
  | Unicode | Names that compare equal on one platform and not another |
69
69
 
70
70
  Media inspection uses `ffprobe` when it is on your PATH. When it is not, those
71
- checks are reported as **skipped** — never as passed. A silent skip is how a
71
+ checks are reported as **skipped**, never as passed. A silent skip is how a
72
72
  conformance tool starts lying.
73
73
 
74
74
  ## For live events, the running order does the work
75
75
 
76
76
  An event agenda is structured data that exists weeks before anyone turns a
77
- camera on. Joined against time-of-day timecode, every recording names itself —
78
- stage, session, speaker, sponsor — with no inference of any kind.
77
+ camera on. Joined against time-of-day timecode, every recording names itself,
78
+ with its stage, session, speaker and sponsor, and no inference of any kind.
79
79
 
80
80
  It will also tell you when a filename is well-formed and still wrong: `S007`
81
81
  recorded during `S011` is a valid shape carrying a false claim, and only the
@@ -1,4 +1,4 @@
1
- # ShuttleCheck — validator
1
+ # ShuttleCheck, the validator
2
2
 
3
3
  Naming conformance for a folder tree: infer a spec from it, check it, fix it,
4
4
  undo the fix, and send the convention with the media. Installs as `shuttlecheck`;
@@ -16,7 +16,7 @@ talked into running on a live job, because running it cannot cost them anything.
16
16
  python -m pip install -e .
17
17
  ```
18
18
 
19
- `tzdata` is a real runtime dependency on Windows — the platform ships no system
19
+ `tzdata` is a real runtime dependency on Windows, because the platform ships no system
20
20
  timezone database, so `zoneinfo` cannot resolve the agenda timezone without it.
21
21
 
22
22
  ## Use
@@ -109,7 +109,7 @@ Delivery rules, for contexts that declare a `deliverable:` profile:
109
109
  | `missing-sidecar` | error | Captions or checksum did not arrive with the file |
110
110
  | `no-video-stream`, `no-audio-stream`, `unreadable-media` | error | Not the kind of file it claims to be |
111
111
  | `color-*-mismatch` | error | Tagged with the wrong colour space |
112
- | `color-*-unknown` | warning | Not tagged at all — absence is not proof of wrongness |
112
+ | `color-*-unknown` | warning | Not tagged at all. Absence is not proof of wrongness |
113
113
  | `incomplete-package` | error | A session or episode is short a deliverable |
114
114
  | `spec-unknown-deliverable` | error | A context points at a profile that is not defined |
115
115
 
@@ -120,7 +120,7 @@ Severities are overridable per rule in the spec's `rules:` block.
120
120
  `shuttlecheck ui` is a local web UI, not Electron and not Qt. The page ships
121
121
  inside the wheel and is served by the standard library's HTTP server, so the
122
122
  install stays `pip install shuttlecheck`, nothing is downloaded at runtime, and
123
- it works with no connection at all — which matters, because the machine this
123
+ it works with no connection at all, which matters, because the machine this
124
124
  runs on is often in a truck. No web fonts either; the stylesheet falls back to
125
125
  whatever the OS has.
126
126
 
@@ -134,7 +134,7 @@ in the URL the browser is opened with, and never leaves the machine.
134
134
 
135
135
  **Applying a plan requires echoing back the move count.** The page states how
136
136
  many moves it believes it is approving. If the folder changed since the plan
137
- was drawn, the numbers disagree and nothing runs — a stale tab cannot apply a
137
+ was drawn, the numbers disagree and nothing runs. A stale tab cannot apply a
138
138
  plan that no longer matches reality.
139
139
 
140
140
  ## How inference works
@@ -150,14 +150,14 @@ to try.
150
150
  has a different field count, anchor from the left and the right while the
151
151
  columns still look like fixed codes and treat the remainder as one variable
152
152
  field. Names structurally unrelated to the rest are set aside first, and
153
- reported — they are usually the existing violations.
153
+ reported. They are usually the existing violations.
154
154
  3. **Profile** each column: constant, prefixed counter, enum, date, slug, or
155
155
  free text, each with a confidence and a plain-English reason.
156
156
  4. **Reconcile** value sets across groups, so a column that reads as the
157
157
  constant `HALLA` in one group and the enum `{HALLA, HALLB}` in another
158
158
  becomes one token rather than two.
159
- 5. **Name** the fields from prefix semantics rather than exact widths — `D01`
160
- and `DAY1` are both `{day}` — falling back to `{field3}` and flagging it.
159
+ 5. **Name** the fields from prefix semantics rather than exact widths, so `D01`
160
+ and `DAY1` are both `{day}`, falling back to `{field3}` and flagging it.
161
161
  6. **Self-validate**: run the validator over the same tree with the spec just
162
162
  inferred, and show the result. That is the thirty-second demonstration.
163
163
 
@@ -175,14 +175,14 @@ comments of the file it writes.
175
175
  It renames only what it can *derive*. For each non-conforming file:
176
176
 
177
177
  1. Parse the folder and the filename against the context's patterns.
178
- 2. If both parse but disagree on a token, **skip** — there is no safe way to
178
+ 2. If both parse but disagree on a token, **skip**. There is no safe way to
179
179
  tell which one is the mistake, so it is a question for a human.
180
180
  3. If the name fails on a single field, recover that one field: from the folder
181
181
  path, by matching a known alias, by re-applying the token's own transforms
182
182
  (which fixes casing, spaces and punctuation), or from its declared source.
183
183
  4. If a context sets `schedule_check`, cross-check the schedule-bound tokens
184
184
  against the agenda and correct them. A name can parse perfectly and still be
185
- wrong — `S001` recorded during `S005` is a valid shape carrying a false
185
+ wrong: `S001` recorded during `S005` is a valid shape carrying a false
186
186
  claim. An ambiguous clock window corrects nothing rather than guessing.
187
187
  5. If the correct name still cannot be derived, **skip and say why**. Inventing
188
188
  a session number is worse than leaving a bad filename.
@@ -194,7 +194,7 @@ Safety, none of it optional:
194
194
  | Dry run | The default. `--apply` is a separate, explicit act. |
195
195
  | Originals | `rename: never` contexts are never planned, moved, or renamed. |
196
196
  | Overwrite | Never. A destination that exists aborts the whole plan. |
197
- | Collisions | Two files resolving to one name abort everything — a half-renamed job is worse than an unrenamed one. |
197
+ | Collisions | Two files resolving to one name abort everything. A half-renamed job is worse than an unrenamed one. |
198
198
  | Stale plans | Every source is re-checked for size and mtime before the first move. |
199
199
  | Case-only renames | Routed through a temporary name, as are swaps and cycles. |
200
200
  | Mid-run failure | Every completed move is rolled back, and the run is marked `rolled_back` in the journal. |
@@ -251,7 +251,7 @@ WHAT YOU ARE DELIVERING
251
251
  with Subtitles ({stem}.srt)
252
252
  ```
253
253
 
254
- The pack is **derived**, never authored separately — a vendor cannot drift from
254
+ The pack is **derived**, never authored separately, so a vendor cannot drift from
255
255
  a document they did not write. `vendors` is the other direction: point it at the
256
256
  incoming deliveries and it reports who is clear, who has errors, and who has
257
257
  sent nothing at all.
@@ -260,7 +260,7 @@ Two things worth knowing:
260
260
 
261
261
  **Out-of-scope contexts are kept as inert stubs, not deleted.** Contexts are
262
262
  first-match-wins, so removing an early one silently re-routes its files into a
263
- later catch-all — the ISO rushes would come back reported as malformed masters.
263
+ later catch-all, and the ISO rushes would come back reported as malformed masters.
264
264
  The stubs preserve ordering and say plainly: not yours.
265
265
 
266
266
  **A vendor is never shown a rule they cannot act on.** Packages narrow to the
@@ -275,7 +275,7 @@ intended.
275
275
 
276
276
  Its second job matters more than undo. Every file carries an `original_path`,
277
277
  recorded the first time it is touched and never rewritten. When a spec is
278
- edited and the tree re-conformed, names are computed from that identity — never
278
+ edited and the tree re-conformed, names are computed from that identity, never
279
279
  from the current name. Without it, tokens compound on every pass: the second
280
280
  run re-prefixes what the first already prefixed, and by the third edit the
281
281
  names are unrecoverable. That failure is silent and looks like it worked.
@@ -294,7 +294,7 @@ drive out of a Peli case. `seal` writes `_SPEC/` onto the drive itself:
294
294
  | `README.txt` | plain-language instructions for someone who has never heard of this tool |
295
295
  | `journal/` | what has been renamed, and by which spec revision |
296
296
 
297
- The receiving end runs `shuttlecheck verify .` — no spec to be told about, no
297
+ The receiving end runs `shuttlecheck verify .` with no spec to be told about, no
298
298
  account, no connectivity. Sealing never touches the media; it only writes
299
299
  inside `_SPEC/`.
300
300
 
@@ -302,19 +302,19 @@ What `verify` reports:
302
302
 
303
303
  | | |
304
304
  |---|---|
305
- | `missing` | error — on the manifest, not on the drive; the copy is incomplete |
306
- | `size-mismatch` | error — damaged or replaced in transit |
307
- | `hash-mismatch` | error — same size, different contents (needs `--hash` at both ends) |
308
- | `added` | warning — on the drive but not the manifest |
309
- | `mtime-drift` | info — most copy tools do not preserve timestamps |
310
- | `clock-did-not-survive-copy` | warning — see below |
305
+ | `missing` | error: on the manifest, not on the drive; the copy is incomplete |
306
+ | `size-mismatch` | error: damaged or replaced in transit |
307
+ | `hash-mismatch` | error: same size, different contents (needs `--hash` at both ends) |
308
+ | `added` | warning: on the drive but not the manifest |
309
+ | `mtime-drift` | info: most copy tools do not preserve timestamps |
310
+ | `clock-did-not-survive-copy` | warning: see below |
311
311
 
312
312
  Two judgement calls worth knowing about.
313
313
 
314
314
  **Timestamp drift is not damage.** Most copy tools do not preserve mtime, so
315
315
  treating that as corruption would make every genuine transfer look broken.
316
316
  *But* if the spec joins the agenda on `file_mtime`, drift silently moves every
317
- recording to the wrong session and the naming findings become fiction — so that
317
+ recording to the wrong session and the naming findings become fiction, so that
318
318
  specific combination is raised as a warning rather than left to be discovered.
319
319
  A spec using embedded timecode does not have the problem.
320
320
 
@@ -328,7 +328,7 @@ timestamps are all that can be checked, and the sidecar says so in writing.
328
328
  **It parses, it does not render.** The validator compiles each naming pattern
329
329
  into a regex whose groups are constrained by each token's own type, enum values,
330
330
  and transforms, then matches the existing name against it. That is why it needs
331
- no prompting, no schedule, and no media parser to do its core job — and why
331
+ no prompting, no schedule, and no media parser to do its core job, and why
332
332
  `prompt`-sourced tokens validate fine.
333
333
 
334
334
  **Mismatches name the field.** On a failure it recompiles the pattern with each
@@ -363,7 +363,7 @@ shuttlecheck validate ./fixture --spec ./fixture/EXC26/_SPEC/spec.yaml
363
363
  ```
364
364
 
365
365
  `tests/make_infer_fixture.py` writes a tree using a convention the code has
366
- never seen — dashes instead of underscores, `DAY1` instead of `D01`, `SES001`
366
+ never seen: dashes instead of underscores, `DAY1` instead of `D01`, `SES001`
367
367
  instead of `S001`, variable-length titles containing the separator, and
368
368
  camera-native ISO names. If inference recovers that, it is reading the tree
369
369
  rather than recognising something baked into the source:
@@ -379,7 +379,7 @@ errors in the files it learned from.
379
379
 
380
380
  Portability rules are unit-tested against the check functions rather than real
381
381
  files, because Windows cannot create a file named `NUL.mov`, `bad<name>.mov`, or
382
- `trailing..mov.` — which is the point of the check.
382
+ `trailing..mov.`, which is the point of the check.
383
383
 
384
384
  ## Not built yet
385
385
 
@@ -388,5 +388,5 @@ files, because Windows cannot create a file named `NUL.mov`, `bad<name>.mov`, or
388
388
  creation time via `ffprobe` and degrades honestly without it.
389
389
  - Agenda formats other than CSV. `ics`, `sched`, `swapcard`, and `cvent` are in
390
390
  the schema and raise `ScheduleUnavailable` rather than failing the run.
391
- - The conformer, its journal, and revert. Deliberately absent — see the
391
+ - The conformer, its journal, and revert. Deliberately absent: see the
392
392
  read-only boundary above.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shuttlecheck"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Check media filenames, folder structure and deliverables against a written convention. Read-only."
5
5
  readme = { file = "PYPI.md", content-type = "text/markdown" }
6
6
  requires-python = ">=3.11"
@@ -29,6 +29,13 @@ classifiers = [
29
29
  dependencies = [
30
30
  "PyYAML>=6.0",
31
31
  "jsonschema>=4.20",
32
+ # xxh64 is the checksum the ASC MHL specification is usually written with
33
+ # and the one DailiesDrop records, and it is not in the standard library.
34
+ # A dependency in the read-only half looks like the wrong place for it
35
+ # until you notice that verifying a drive the appliance sealed is exactly
36
+ # what this half is for: without it, the common case degrades to "this
37
+ # installation cannot check those checksums".
38
+ "xxhash>=3.0",
32
39
  # Windows ships no system tz database, so zoneinfo cannot resolve the
33
40
  # agenda timezone without this. Required, not optional, on a target platform.
34
41
  "tzdata>=2024.1; sys_platform == 'win32'",
@@ -1,18 +1,18 @@
1
- """ShuttleCheck -- naming and delivery conformance.
1
+ """ShuttleCheck: naming and delivery conformance.
2
2
 
3
3
  Three entry points, in the order you would use them:
4
4
 
5
5
  infer_spec / walkthrough read an existing tree and write the spec it follows
6
6
  Validator / validate report what does not conform, in name and in
7
- substance -- codec, resolution, audio, packages
7
+ substance: codec, resolution, audio, packages
8
8
  verify check a drive against the sidecar it arrived with
9
9
 
10
10
  **Nothing in this package modifies media.** That is the whole of it, and it is
11
11
  deliberate: a checker that cannot alter anything is a checker a stranger will
12
12
  run on a live job, because running it cannot cost them anything.
13
13
 
14
- The operations that write -- conforming, the journal, sealing a drive, vendor
15
- packs -- are licensed separately and ship as `shuttlecheck_pro`. When it is
14
+ The operations that write (conforming, the journal, sealing a drive, vendor
15
+ packs) are licensed separately and ship as `shuttlecheck_pro`. When it is
16
16
  installed, `extensions.load()` finds it and the CLI and app grow the extra
17
17
  commands; nothing here imports it, and nothing here requires it.
18
18
  """
@@ -10,8 +10,8 @@ None of the above modifies media. That is the whole of this package, and it is
10
10
  deliberate: a checker that cannot alter anything is a checker a stranger will
11
11
  run on a live job.
12
12
 
13
- Installing ShuttleCheck Pro adds the operations that write -- conform, revert,
14
- history, seal, pack, vendors -- and they appear here as extra subcommands. When
13
+ Installing ShuttleCheck Pro adds the operations that write (conform, revert,
14
+ history, seal, pack, vendors) and they appear here as extra subcommands. When
15
15
  it is absent those names are still registered, so asking for one gets an
16
16
  explanation rather than "invalid choice".
17
17
  """
@@ -55,6 +55,18 @@ def build_parser() -> argparse.ArgumentParser:
55
55
  c = sub.add_parser("check-spec", help="validate the spec itself and exit")
56
56
  c.add_argument("--spec", required=True)
57
57
 
58
+ b = sub.add_parser(
59
+ "support",
60
+ help="write one file to send us when something has gone wrong")
61
+ b.add_argument("--names", action="store_true",
62
+ help="replace every filename with a stand-in, for media "
63
+ "you are not allowed to share")
64
+ b.add_argument("--job", action="append", dest="jobs", default=None,
65
+ help="a specific job id; repeatable (default: the most recent)")
66
+ b.add_argument("--out", help="where to write it")
67
+ b.add_argument("--yes", action="store_true",
68
+ help="write it without printing the contents first")
69
+
58
70
  u = sub.add_parser("ui", help="open the local app in a browser")
59
71
  u.add_argument("root", nargs="?", help="folder to open on (optional)")
60
72
  u.add_argument("--port", type=int, default=8765)
@@ -100,7 +112,7 @@ def _add_licensed_stubs(sub) -> None:
100
112
  installed. These accept anything and fail with a sentence.
101
113
  """
102
114
  for name in LICENSED:
103
- stub = sub.add_parser(name, help="(ShuttleCheck Pro -- not installed)",
115
+ stub = sub.add_parser(name, help="(ShuttleCheck Pro, not installed)",
104
116
  add_help=False)
105
117
  stub.add_argument("args", nargs=argparse.REMAINDER)
106
118
 
@@ -215,8 +227,52 @@ def _verify(args) -> int:
215
227
  return 1 if result.naming is not None and result.naming.errors else 0
216
228
 
217
229
 
230
+ def _support(args) -> int:
231
+ """Write the bundle, after saying what is going in it.
232
+
233
+ Printed first and by default. Somebody running this is about to email a
234
+ file to a company, and "it only contains diagnostics" is the kind of
235
+ sentence that is always said and rarely checked. So it is not said: the
236
+ contents are listed, and the flag to skip the listing is opt-in.
237
+ """
238
+ from . import support
239
+
240
+ mode = support.NO_NAMES if args.names else support.FULL
241
+ items = support.manifest(args.jobs, mode)
242
+
243
+ where = "names removed" if args.names else "full"
244
+ print(f"\n A support bundle in {where} mode would contain:\n")
245
+ for item in items:
246
+ mark = "names " if item.names else " "
247
+ print(f" {mark} {item.name}")
248
+ print(f" {item.what}")
249
+ if mode == support.FULL and any(i.names for i in items):
250
+ print("\n The lines marked 'names' carry filenames from your media."
251
+ "\n Run it again with --names if you cannot send those.")
252
+
253
+ if not args.yes:
254
+ try:
255
+ answer = input("\n Write it? [y/N] ").strip().lower()
256
+ except EOFError:
257
+ # Run from a script, or with no terminal attached. Declining is
258
+ # the safe reading of silence here: this writes a file somebody is
259
+ # about to email. `--yes` is how a script says yes.
260
+ print("\n Nothing was written: there is nobody here to ask. "
261
+ "Pass --yes to write it without the question.")
262
+ return 0
263
+ if answer not in ("y", "yes"):
264
+ print(" Nothing was written.")
265
+ return 0
266
+
267
+ bundle = support.build(Path(args.out) if args.out else None, args.jobs, mode)
268
+ print(f"\n {bundle.bytes:,} bytes written to\n {bundle.path}")
269
+ print("\n Nothing was uploaded. Send it to hello@mvrk.systems when you "
270
+ "are ready.")
271
+ return 0
272
+
273
+
218
274
  COMMANDS = {"ui": _ui, "validate": _validate, "check-spec": _check_spec,
219
- "init": _init, "verify": _verify}
275
+ "init": _init, "verify": _verify, "support": _support}
220
276
 
221
277
 
222
278
  def _handlers() -> dict:
@@ -0,0 +1,227 @@
1
+ """Reconciling what was shot against what arrived.
2
+
3
+ Every other check in this tool reasons about files that exist. That is a whole
4
+ class of failure it cannot see, and it is the expensive one: a reel that was
5
+ shot and never ingested produces no files, so it forms no group, matches no
6
+ context, and breaks no rule. The delivery passes. Nobody finds out until an
7
+ edit assistant goes looking for B_R004 three weeks later and it has been wiped
8
+ off the card.
9
+
10
+ `check_packages` in `delivery.py` is the closest existing thing and it has the
11
+ same blind spot by construction: it groups the records it was given and asks
12
+ whether each group is short. A group with no members is not short, it is
13
+ absent, and absence is invisible from the inside.
14
+
15
+ The only way out is a declaration from outside the media: a camera report, a
16
+ sound report, a shooting log. The production already has one; it is the
17
+ paperwork that says which rolls exist. This module reads it and subtracts.
18
+
19
+ **Where it runs matters as much as what it does.** The declaration names reels,
20
+ and a reel identifier carries the production, the camera and the roll. It is
21
+ reconciled here, on the destination, from paperwork the production has already
22
+ handed over, never on our servers. See `docs/DATA_BOUNDARY.md` section 4.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import csv
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ from .report import Finding
32
+
33
+ # A row that declares nothing is not a declaration, and a camera report often
34
+ # ends with a blank line or a totals row. Both would otherwise become a reel
35
+ # that was never delivered, which is a false alarm about missing footage --
36
+ # the most expensive kind of false alarm there is.
37
+ SKIP_EMPTY = True
38
+
39
+
40
+ class ManifestUnavailable(Exception):
41
+ """The declaration could not be read. Never silently treated as empty.
42
+
43
+ An empty declaration and an unreadable one look identical afterwards: both
44
+ produce no missing reels. One of them means everything arrived and the
45
+ other means nobody checked, and this tool does not let a check that could
46
+ not run report as a pass.
47
+ """
48
+
49
+
50
+ @dataclass
51
+ class Declared:
52
+ """One unit of work somebody says exists."""
53
+
54
+ key: tuple
55
+ fields: dict = field(default_factory=dict)
56
+ row: int = 0
57
+ source: str = ""
58
+
59
+ @property
60
+ def label(self) -> str:
61
+ return " ".join(f"{k}={v}" for k, v in self.fields.items())
62
+
63
+
64
+ @dataclass
65
+ class Ledger:
66
+ """What was declared, what arrived, and the difference in both directions."""
67
+
68
+ source: str = ""
69
+ key_tokens: list = field(default_factory=list)
70
+ declared: list = field(default_factory=list)
71
+ delivered: dict = field(default_factory=dict) # key -> file count
72
+ missing: list = field(default_factory=list) # Declared, nothing arrived
73
+ undeclared: list = field(default_factory=list) # arrived, nothing declared
74
+
75
+ @property
76
+ def complete(self) -> bool:
77
+ return not self.missing
78
+
79
+ @property
80
+ def arrived(self) -> int:
81
+ """Declared work that something arrived for.
82
+
83
+ Counted against the declaration, not against everything that turned
84
+ up. Counting every key with files in it read "6 ingested of 6
85
+ declared" on a job with one reel missing and one nobody declared --
86
+ two errors cancelling into a reassuring number, which is the worst
87
+ arithmetic this screen could do.
88
+ """
89
+ keys = {d.key for d in self.declared}
90
+ return len([k for k, n in self.delivered.items() if n and k in keys])
91
+
92
+ def summary(self) -> dict:
93
+ return {
94
+ "source": self.source,
95
+ "keyTokens": list(self.key_tokens),
96
+ "declared": len(self.declared),
97
+ "delivered": self.arrived,
98
+ "files": sum(self.delivered.values()),
99
+ "missing": [{"label": d.label, "fields": d.fields, "row": d.row}
100
+ for d in self.missing],
101
+ "undeclared": [{"label": " ".join(f"{k}={v}" for k, v in dict(
102
+ zip(self.key_tokens, key)).items()), "files": self.delivered[key]}
103
+ for key in self.undeclared],
104
+ "complete": self.complete,
105
+ }
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Reading the declaration
110
+ # --------------------------------------------------------------------------
111
+
112
+ def load_manifest(source: dict, spec_dir: Path, name: str = "") -> list[Declared]:
113
+ """Read a camera report, or anything else shaped like one.
114
+
115
+ CSV only for now, and it says so rather than pretending. A camera report
116
+ is a table with one row per roll; the map says which of its columns are
117
+ which of the convention's fields, because nobody names their columns the
118
+ same way twice.
119
+ """
120
+ imp = source.get("import") or {}
121
+ fmt = imp.get("format")
122
+ if fmt != "csv":
123
+ raise ManifestUnavailable(
124
+ f"manifest format '{fmt}' is not implemented yet (only csv)")
125
+
126
+ raw = imp.get("path", "")
127
+ path = (spec_dir / raw).resolve()
128
+ if not path.is_file():
129
+ raise ManifestUnavailable(f"manifest file not found: {path}")
130
+
131
+ key_tokens = list(source.get("key") or [])
132
+ if not key_tokens:
133
+ raise ManifestUnavailable(
134
+ f"manifest '{name}' declares no key, so there is nothing to "
135
+ f"reconcile against: add `key: [unit, reel]` or similar")
136
+
137
+ column = imp.get("map") or {}
138
+ out: list[Declared] = []
139
+ try:
140
+ with path.open(encoding="utf-8-sig", newline="") as fh:
141
+ for number, row in enumerate(csv.DictReader(fh), start=2):
142
+ fields = {}
143
+ for token in key_tokens:
144
+ value = (row.get(column.get(token, token)) or "").strip()
145
+ fields[token] = value
146
+ if SKIP_EMPTY and not all(fields.values()):
147
+ continue
148
+ out.append(Declared(key=tuple(fields[t] for t in key_tokens),
149
+ fields=fields, row=number, source=name))
150
+ except OSError as exc:
151
+ raise ManifestUnavailable(f"manifest could not be read: {exc}")
152
+
153
+ if not out:
154
+ raise ManifestUnavailable(
155
+ f"manifest '{name}' has no usable rows. Check the column names in "
156
+ f"`map` against the file's header: {path.name}")
157
+ return out
158
+
159
+
160
+ # --------------------------------------------------------------------------
161
+ # Subtracting
162
+ # --------------------------------------------------------------------------
163
+
164
+ def reconcile(declared: list[Declared], records, key_tokens: list,
165
+ source: str = "") -> Ledger:
166
+ """What arrived, against what was declared.
167
+
168
+ Both directions are reported. A declared reel that never arrived is the
169
+ expensive one. A reel that arrived and was never declared is cheaper and
170
+ still worth a look: usually a typo in one place or the other, occasionally
171
+ somebody's second camera that nobody told the office about.
172
+ """
173
+ ledger = Ledger(source=source, key_tokens=list(key_tokens),
174
+ declared=list(declared))
175
+
176
+ for record in records:
177
+ if not all(t in record.tokens for t in key_tokens):
178
+ continue
179
+ key = tuple(record.tokens[t] for t in key_tokens)
180
+ ledger.delivered[key] = ledger.delivered.get(key, 0) + 1
181
+
182
+ declared_keys = {d.key for d in declared}
183
+ for entry in declared:
184
+ if not ledger.delivered.get(entry.key):
185
+ ledger.delivered.setdefault(entry.key, 0)
186
+ ledger.missing.append(entry)
187
+
188
+ ledger.undeclared = sorted(k for k, n in ledger.delivered.items()
189
+ if n and k not in declared_keys)
190
+ return ledger
191
+
192
+
193
+ def findings(ledger: Ledger, severities) -> list[Finding]:
194
+ """A missing reel is an error in the report, not a note on a side screen.
195
+
196
+ It belongs beside the other errors for the same reason the package check
197
+ does: this is the failure that costs somebody their deadline, and a person
198
+ who never opens the fleet view has still been told.
199
+ """
200
+ out = []
201
+ for entry in ledger.missing:
202
+ out.append(Finding(
203
+ rule="declared-never-delivered",
204
+ severity=severities("declared-never-delivered", "error"),
205
+ path=entry.label or "(unnamed)", context=ledger.source,
206
+ message=(f"{entry.label} is listed in {ledger.source or 'the manifest'} "
207
+ f"(row {entry.row}) and nothing was ever delivered for it"),
208
+ detail={"source": ledger.source, "row": entry.row,
209
+ "fields": dict(entry.fields)},
210
+ ))
211
+ for key in ledger.undeclared:
212
+ described = " ".join(f"{t}={v}" for t, v in zip(ledger.key_tokens, key))
213
+ out.append(Finding(
214
+ rule="delivered-never-declared",
215
+ severity=severities("delivered-never-declared", "warning"),
216
+ path=described, context=ledger.source,
217
+ message=(f"{described} was delivered but is not listed in "
218
+ f"{ledger.source or 'the manifest'}"),
219
+ detail={"source": ledger.source, "files": ledger.delivered[key]},
220
+ ))
221
+ return out
222
+
223
+
224
+ def manifests(spec) -> dict:
225
+ """The manifest sources declared in a convention, if any."""
226
+ return {name: source for name, source in (spec.sources or {}).items()
227
+ if source.get("type") == "manifest"}
@@ -9,7 +9,7 @@ The same engine as the naming rules, one level up: a written spec, a read-only
9
9
  check, and a report that names the exact field that is wrong.
10
10
 
11
11
  Media inspection goes through `Prober`. When no prober is available every media
12
- check reports as **skipped**, never as passed -- the rule that matters more here
12
+ check reports as **skipped**, never as passed, the rule that matters more here
13
13
  than anywhere else in this tool, because a delivery report that silently checked
14
14
  nothing is worse than no report at all.
15
15
  """
@@ -429,7 +429,7 @@ def _check_audio(rule: dict, info: MediaInfo, rel, label, severity, fail):
429
429
 
430
430
  def check_sidecars(profile: dict, path: Path, root: Path, rel: str, *,
431
431
  label: str) -> list[Finding]:
432
- """Files that must arrive alongside the deliverable -- captions, checksums."""
432
+ """Files that must arrive alongside the deliverable: captions, checksums."""
433
433
  out: list[Finding] = []
434
434
  for entry in profile.get("sidecars") or []:
435
435
  pattern = entry.get("pattern", "")
@@ -2,7 +2,7 @@
2
2
 
3
3
  The open package is the read-only half: work out a convention, check a tree
4
4
  against it, check a drive that arrived. Everything that writes to media --
5
- conforming, the journal, sealing, vendor packs -- ships separately and is
5
+ conforming, the journal, sealing, vendor packs, ships separately and is
6
6
  licensed.
7
7
 
8
8
  The base package must never require the extension, must never import it at
@@ -42,7 +42,7 @@ def require(what: str):
42
42
  raise ExtensionMissing(
43
43
  f"{what} is part of ShuttleCheck Pro, which is not installed on this "
44
44
  f"machine.\n"
45
- f"The open package covers init, validate, verify and the app -- "
45
+ f"The open package covers init, validate, verify and the app. "
46
46
  f"everything that only reads.\n"
47
47
  f"Conforming, sealing, the journal and vendor packs are licensed: "
48
48
  f"https://mvrk.systems/shuttlecheck")