shuttlecheck 0.2.1__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.
- {shuttlecheck-0.2.1/shuttlecheck.egg-info → shuttlecheck-0.3.0}/PKG-INFO +7 -6
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/PYPI.md +5 -5
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/README.md +26 -26
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/pyproject.toml +8 -1
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/__init__.py +4 -4
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/cli.py +60 -4
- shuttlecheck-0.3.0/shuttlecheck/completeness.py +227 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/delivery.py +2 -2
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/extensions.py +2 -2
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/infer.py +2 -2
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/jobs.py +44 -19
- shuttlecheck-0.3.0/shuttlecheck/mhl.py +454 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/patterns.py +3 -3
- shuttlecheck-0.3.0/shuttlecheck/receive.py +517 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/report.py +1 -1
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/schedule.py +2 -2
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/schema.json +15 -1
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/sidecar.py +235 -7
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/spec.py +134 -6
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/store.py +27 -0
- shuttlecheck-0.3.0/shuttlecheck/support.py +465 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/ui.py +195 -15
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/ui_assets/app.css +223 -22
- shuttlecheck-0.3.0/shuttlecheck/ui_assets/app.js +2361 -0
- shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/inter-LICENSE.txt +93 -0
- shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/inter.woff2 +0 -0
- shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/mono-LICENSE.txt +93 -0
- shuttlecheck-0.3.0/shuttlecheck/ui_assets/fonts/mono.woff2 +0 -0
- shuttlecheck-0.3.0/shuttlecheck/updates.py +154 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/validate.py +49 -7
- shuttlecheck-0.3.0/shuttlecheck/version.py +1 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/walkthrough.py +4 -4
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0/shuttlecheck.egg-info}/PKG-INFO +7 -6
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/SOURCES.txt +17 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/requires.txt +1 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_cli.py +28 -1
- shuttlecheck-0.3.0/tests/test_completeness.py +361 -0
- shuttlecheck-0.3.0/tests/test_fonts.py +118 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_infer.py +1 -1
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_jobs.py +30 -0
- shuttlecheck-0.3.0/tests/test_mhl.py +343 -0
- shuttlecheck-0.3.0/tests/test_min_version.py +134 -0
- shuttlecheck-0.3.0/tests/test_receive.py +371 -0
- shuttlecheck-0.3.0/tests/test_rename_token.py +161 -0
- shuttlecheck-0.3.0/tests/test_support.py +292 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_ui.py +12 -7
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_ui_routes.py +125 -0
- shuttlecheck-0.3.0/tests/test_updates.py +263 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_validate.py +3 -3
- shuttlecheck-0.2.1/shuttlecheck/ui_assets/app.js +0 -934
- shuttlecheck-0.2.1/shuttlecheck/version.py +0 -1
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/LICENSE +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/setup.cfg +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/auth.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/checks.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/originals.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/telemetry-v1.schema.json +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/telemetry.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck/ui_assets/app.html +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/dependency_links.txt +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/entry_points.txt +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/shuttlecheck.egg-info/top_level.txt +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_auth.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_classify.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_delivery.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_groups.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_infer_keys.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_no_convention.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_originals.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_path_length.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_pick.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_store.py +0 -0
- {shuttlecheck-0.2.1 → shuttlecheck-0.3.0}/tests/test_telemetry.py +0 -0
- {shuttlecheck-0.2.1 → 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
160
|
-
and `DAY1` are both `{day}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 .`
|
|
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
|
|
306
|
-
| `size-mismatch` | error
|
|
307
|
-
| `hash-mismatch` | error
|
|
308
|
-
| `added` | warning
|
|
309
|
-
| `mtime-drift` | info
|
|
310
|
-
| `clock-did-not-survive-copy` | warning
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
15
|
-
packs
|
|
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
|
|
14
|
-
history, seal, pack, vendors
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|