shuttlecheck 0.4.0__tar.gz → 0.4.2__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 (157) hide show
  1. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/PKG-INFO +1 -1
  2. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/pyproject.toml +2 -2
  3. shuttlecheck-0.4.2/shuttlecheck/certificate.py +110 -0
  4. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/checks.py +1 -1
  5. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/cli.py +9 -2
  6. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/completeness.py +44 -5
  7. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/conventions.py +20 -0
  8. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/delivery.py +4 -1
  9. shuttlecheck-0.4.2/shuttlecheck/demo.py +160 -0
  10. shuttlecheck-0.4.2/shuttlecheck/demo_convention.yaml +411 -0
  11. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/jobs.py +17 -1
  12. shuttlecheck-0.4.2/shuttlecheck/layoutlog.py +66 -0
  13. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/mhl.py +1 -1
  14. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/prefolder.py +82 -2
  15. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/provenance.py +5 -1
  16. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/say.py +17 -0
  17. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/seal.py +24 -0
  18. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/seal_api.py +74 -5
  19. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/sidecar.py +1 -1
  20. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/today.py +56 -6
  21. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui.py +222 -4
  22. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/app.css +23 -23
  23. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/app.html +1 -0
  24. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/app.js +455 -94
  25. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/validate.py +1 -1
  26. shuttlecheck-0.4.2/shuttlecheck/version.py +1 -0
  27. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/PKG-INFO +1 -1
  28. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/SOURCES.txt +4 -0
  29. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_jobs.py +1 -1
  30. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_prefolder.py +42 -0
  31. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_the_04_window.py +125 -3
  32. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_window.py +41 -0
  33. shuttlecheck-0.4.0/shuttlecheck/version.py +0 -1
  34. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/LICENSE +0 -0
  35. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/PYPI.md +0 -0
  36. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/README.md +0 -0
  37. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/setup.cfg +0 -0
  38. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/__init__.py +0 -0
  39. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/admission.py +0 -0
  40. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ale.py +0 -0
  41. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/aliases.py +0 -0
  42. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/arrivals.py +0 -0
  43. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/attribution.py +0 -0
  44. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/auth.py +0 -0
  45. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/endpoints.py +0 -0
  46. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/extensions.py +0 -0
  47. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/infer.py +0 -0
  48. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/journal.py +0 -0
  49. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/listener.py +0 -0
  50. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/marks.py +0 -0
  51. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/originals.py +0 -0
  52. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/patterns.py +0 -0
  53. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/probe.py +0 -0
  54. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/receive.py +0 -0
  55. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/report.py +0 -0
  56. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/schedule.py +0 -0
  57. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/schema.json +0 -0
  58. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/sizing.py +0 -0
  59. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/spec.py +0 -0
  60. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/store.py +0 -0
  61. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/support.py +0 -0
  62. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/telemetry-v1.schema.json +0 -0
  63. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/telemetry.py +0 -0
  64. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/templates.py +0 -0
  65. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/transport.py +0 -0
  66. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/classic.css +0 -0
  67. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/classic.html +0 -0
  68. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/classic.js +0 -0
  69. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/display-LICENSE.txt +0 -0
  70. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/display.woff2 +0 -0
  71. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/inter-LICENSE.txt +0 -0
  72. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/inter.woff2 +0 -0
  73. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/mono-LICENSE.txt +0 -0
  74. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/ui_assets/fonts/mono.woff2 +0 -0
  75. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/updates.py +0 -0
  76. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/walkthrough.py +0 -0
  77. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck/window.py +0 -0
  78. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/dependency_links.txt +0 -0
  79. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/entry_points.txt +0 -0
  80. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/requires.txt +0 -0
  81. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/shuttlecheck.egg-info/top_level.txt +0 -0
  82. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_admission.py +0 -0
  83. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_ale.py +0 -0
  84. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_aliases.py +0 -0
  85. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_allocate.py +0 -0
  86. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_arrivals.py +0 -0
  87. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_auth.py +0 -0
  88. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_auth_packaged.py +0 -0
  89. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_check_against.py +0 -0
  90. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_classify.py +0 -0
  91. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_cli.py +0 -0
  92. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_completeness.py +0 -0
  93. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_consoles.py +0 -0
  94. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_conventions.py +0 -0
  95. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_conventions_routes.py +0 -0
  96. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_counting_files.py +0 -0
  97. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_dailies.py +0 -0
  98. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_dailiesdrop.py +0 -0
  99. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_delivery.py +0 -0
  100. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_device_flow.py +0 -0
  101. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_due.py +0 -0
  102. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_endpoints.py +0 -0
  103. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_enrolment.py +0 -0
  104. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_fleet_units.py +0 -0
  105. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_folders.py +0 -0
  106. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_fonts.py +0 -0
  107. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_groups.py +0 -0
  108. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_infer.py +0 -0
  109. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_infer_keys.py +0 -0
  110. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_keepable.py +0 -0
  111. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_lamps.py +0 -0
  112. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_listener.py +0 -0
  113. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_marks_api.py +0 -0
  114. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_message_style.py +0 -0
  115. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_meters_measure.py +0 -0
  116. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_mhl.py +0 -0
  117. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_min_version.py +0 -0
  118. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_monitor.py +0 -0
  119. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_no_convention.py +0 -0
  120. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_one_file_is_not_a_pattern.py +0 -0
  121. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_originals.py +0 -0
  122. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_path_length.py +0 -0
  123. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_pick.py +0 -0
  124. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_pointed_at_the_job.py +0 -0
  125. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_problems.py +0 -0
  126. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_provenance.py +0 -0
  127. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_rack.py +0 -0
  128. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_receive.py +0 -0
  129. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_rename_token.py +0 -0
  130. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_report.py +0 -0
  131. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_reveal.py +0 -0
  132. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_rollcall_schedule.py +0 -0
  133. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_signal_path.py +0 -0
  134. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_sizing.py +0 -0
  135. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_sizing_history.py +0 -0
  136. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_source_attach.py +0 -0
  137. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_store.py +0 -0
  138. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_support.py +0 -0
  139. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_sweeping.py +0 -0
  140. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_telemetry.py +0 -0
  141. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_templates.py +0 -0
  142. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_the_analyser.py +0 -0
  143. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_the_pipeline.py +0 -0
  144. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_today.py +0 -0
  145. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_today_screen.py +0 -0
  146. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_trademark.py +0 -0
  147. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_transport.py +0 -0
  148. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_ui.py +0 -0
  149. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_ui_routes.py +0 -0
  150. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_unread_sources.py +0 -0
  151. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_updates.py +0 -0
  152. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_validate.py +0 -0
  153. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_verify.py +0 -0
  154. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_walk.py +0 -0
  155. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_web_seal.py +0 -0
  156. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_what_the_far_end_reads.py +0 -0
  157. {shuttlecheck-0.4.0 → shuttlecheck-0.4.2}/tests/test_where_it_belongs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shuttlecheck
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shuttlecheck"
3
- version = "0.4.0"
3
+ version = "0.4.2"
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"
@@ -71,4 +71,4 @@ packages = ["shuttlecheck"]
71
71
  # it is sent, so a build that cannot read it sends nothing at all. Both travel
72
72
  # inside the wheel.
73
73
  shuttlecheck = ["schema.json", "telemetry-v1.schema.json", "ui_assets/*",
74
- "ui_assets/fonts/*"]
74
+ "ui_assets/fonts/*", "demo_convention.yaml"]
@@ -0,0 +1,110 @@
1
+ """The certificate a sealed drive carries.
2
+
3
+ `_SPEC/README.txt` is for somebody who has never heard of any of this and
4
+ opens a text file. `_SPEC/SEALED.html` is the same facts for everybody else:
5
+ the page a post house double-clicks first. It opens in any browser, offline,
6
+ with nothing fetched, and says what was sealed, against which rule, in what
7
+ state, and how to prove it arrived intact without installing anything of ours.
8
+
9
+ It is also the only thing this product puts in front of somebody who is not
10
+ yet a customer, on a drive their own client sent them. So it is plain, exact
11
+ and quietly made, and it does not sell.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+
18
+
19
+ def _esc(value) -> str:
20
+ return html.escape(str(value if value is not None else ""), quote=True)
21
+
22
+
23
+ def render(*, folder: str, project: str, convention: str, sealed: str,
24
+ mhl_name: str, generation: int, algorithm: str, files: int,
25
+ size: str, errors, warnings, custody: str = "",
26
+ tool_version: str = "") -> str:
27
+ # `None` for both is a preview drawn before sealing: the conformance is
28
+ # measured when the drive is sealed, and is never shown as clean before.
29
+ preview = errors is None or warnings is None
30
+ clean = not preview and not errors and not warnings
31
+ state = ("Measured when the drive is sealed" if preview else
32
+ "On the rule" if clean else
33
+ f"{errors} error{'s' if errors != 1 else ''}, "
34
+ f"{warnings} warning{'s' if warnings != 1 else ''} when sealed")
35
+ ring = (f"ASC MHL · {algorithm.upper()} · {files} FILES · GENERATION "
36
+ f"{generation} · ")
37
+ rows = [
38
+ ("Drive", folder), ("Project", project), ("Convention", convention),
39
+ ("Sealed", sealed), ("Hash list", f"ascmhl/{mhl_name}"),
40
+ ("Generation", generation), ("Checksum", algorithm),
41
+ ("Files", files), ("Size", size), ("Conformance", state),
42
+ ]
43
+ if custody:
44
+ rows.append(("Custody", custody))
45
+ table = "\n".join(
46
+ f"<tr><th>{_esc(k)}</th><td>{_esc(v)}</td></tr>" for k, v in rows)
47
+ return f"""<!DOCTYPE html>
48
+ <html lang="en"><head><meta charset="utf-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1">
50
+ <title>{'Not sealed yet' if preview else 'Sealed'}: {_esc(folder)}</title>
51
+ <style>
52
+ :root {{ --bg:#07142A; --panel:#0C1D38; --line:rgba(130,175,225,.2); --fg:#EAF2FB;
53
+ --fg2:#A9BCD3; --cyan:#34E7E3; --ok:#2FBF71; --warn:#FF9A5C; color-scheme: dark; }}
54
+ * {{ box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
55
+ html {{ background: var(--bg); }}
56
+ body {{ margin:0; background:var(--bg); color:var(--fg);
57
+ font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }}
58
+ main {{ max-width:880px; margin:0 auto; padding:48px 20px 64px; display:grid; gap:28px; }}
59
+ .top {{ display:grid; grid-template-columns:220px 1fr; gap:28px; align-items:center; }}
60
+ h1 {{ margin:0; font-size:40px; line-height:1.05; letter-spacing:-.5px; }}
61
+ .sub {{ color:var(--fg2); margin:8px 0 0; }}
62
+ .state {{ display:inline-block; margin-top:14px; padding:4px 12px; border-radius:999px;
63
+ border:1px solid currentColor; font-weight:600; font-size:13px;
64
+ color:{'var(--fg2)' if preview else 'var(--ok)' if clean else 'var(--warn)'}; }}
65
+ table {{ width:100%; border-collapse:collapse; background:var(--panel);
66
+ border:1px solid var(--line); border-radius:12px; overflow:hidden; }}
67
+ th, td {{ text-align:left; padding:10px 16px; border-top:1px solid var(--line); vertical-align:top; }}
68
+ tr:first-child th, tr:first-child td {{ border-top:0; }}
69
+ th {{ width:160px; color:var(--fg2); font-weight:500; }}
70
+ td {{ font-family:ui-monospace,Menlo,Consolas,monospace; font-size:13.5px; word-break:break-all; }}
71
+ h2 {{ font-size:18px; margin:0 0 8px; }}
72
+ p {{ margin:0 0 10px; color:var(--fg2); max-width:68ch; }}
73
+ code {{ font-family:ui-monospace,Menlo,Consolas,monospace; color:var(--fg); }}
74
+ .ribbon {{ display:inline-block; margin:0 0 12px; padding:3px 10px; border-radius:6px;
75
+ background:var(--warn); color:#07142A; font-weight:700; font-size:12px; letter-spacing:.04em; text-transform:uppercase; }}
76
+ footer {{ color:var(--fg2); font-size:13px; border-top:1px solid var(--line); padding-top:16px; }}
77
+ @media (max-width:640px) {{ .top {{ grid-template-columns:1fr; }} }}
78
+ /* Dark on paper too: the certificate is the brand a client's post house
79
+ sees, and a browser printing it drops backgrounds unless told not to. */
80
+ @page {{ margin: 0; background: #07142A; }}
81
+ @media print {{ html, body {{ background: var(--bg) !important; }} main {{ padding: 28px 32px; }}
82
+ table, section {{ break-inside: avoid; }} }}
83
+ </style></head>
84
+ <body><main>
85
+ <div class="top">
86
+ <svg viewBox="0 0 220 220" width="200" height="200" role="img" aria-label="Seal, generation {generation}">
87
+ <defs><path id="r" d="M110 110 m -76 0 a 76 76 0 1 1 152 0 a 76 76 0 1 1 -152 0"/>
88
+ <linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="#0B7AC1"/>
89
+ <stop offset=".6" stop-color="#19A3C8"/><stop offset="1" stop-color="#34E7E3"/></linearGradient></defs>
90
+ <circle cx="110" cy="110" r="96" fill="none" stroke="url(#g)" stroke-width="9"/>
91
+ <circle cx="110" cy="110" r="60" fill="none" stroke="var(--line)"/>
92
+ <text font-size="9.5" fill="var(--fg2)" font-family="ui-monospace,Menlo,monospace"><textPath href="#r" textLength="470" lengthAdjust="spacing">{_esc(ring)}</textPath></text>
93
+ <text x="110" y="102" text-anchor="middle" font-size="11" fill="var(--fg2)">generation</text>
94
+ <text x="110" y="140" text-anchor="middle" font-size="40" font-weight="700" fill="var(--fg)">{_esc(generation)}</text>
95
+ </svg>
96
+ <div>{'<p class="ribbon">Preview. Nothing is sealed yet.</p>' if preview else ''}<h1>{'Ready to seal' if preview else 'Sealed'}: {_esc(folder)}</h1>
97
+ <p class="sub">{'When this drive is sealed, every file on it is hashed into an ASC Media Hash List, and the naming convention it is held to travels with it.' if preview else 'Every file on this drive was hashed into an ASC Media Hash List when it was sealed, and the naming convention it was held to travels with it.'}</p>
98
+ <span class="state">{_esc(state)}</span></div>
99
+ </div>
100
+ <table>{table}</table>
101
+ <section><h2>Prove it arrived intact</h2>
102
+ <p>Use whatever already reads ASC MHL: Pomfort MediaVerify, or the ASC's own <code>ascmhl</code> tool. The hash list in <code>ascmhl/</code> is plain XML and opens in any text editor.</p>
103
+ <p>Or, with ShuttleCheck, free and with no account: <code>pip install shuttlecheck</code>, then <code>shuttlecheck verify .</code> from the folder holding <code>_SPEC</code>. It only reads; nothing is renamed, moved or deleted.</p>
104
+ </section>
105
+ <section><h2>What else is here</h2>
106
+ <p><code>_SPEC/convention.yaml</code> is the naming convention this job follows, <code>_SPEC/report.txt</code> the conformance report as it stood when the drive left, and <code>_SPEC/README.txt</code> the same as this page in plain text.</p>
107
+ </section>
108
+ <footer>Sealed with ShuttleCheck{(' ' + _esc(tool_version)) if tool_version else ''} by MVRK Systems, mvrk.systems. Nothing on this page was fetched from anywhere.</footer>
109
+ </main></body></html>
110
+ """
@@ -169,7 +169,7 @@ def check_structure(spec, dirs: list[str], files_by_dir: dict[str, list[str]],
169
169
  rule="loose-file",
170
170
  severity=spec.severity("loose-file", "warning"),
171
171
  path=d or ".",
172
- message=(f"{count(len(names), 'file')} sit directly in a folder that should "
172
+ message=(f"{count(len(names), 'file')} {'sits' if len(names) == 1 else 'sit'} directly in a folder that should "
173
173
  f"contain only subfolders: {', '.join(sorted(names)[:4])}"
174
174
  + (" ..." if len(names) > 4 else "")),
175
175
  detail={"files": sorted(names)},
@@ -69,11 +69,17 @@ def build_parser() -> argparse.ArgumentParser:
69
69
  b.add_argument("--yes", action="store_true",
70
70
  help="write it without printing the contents first")
71
71
 
72
- u = sub.add_parser("ui", help="open the local app in a browser")
72
+ u = sub.add_parser("ui", help="open the local app in a browser, or in its own window")
73
73
  u.add_argument("root", nargs="?", help="folder to open on (optional)")
74
74
  u.add_argument("--port", type=int, default=8765)
75
75
  u.add_argument("--no-browser", action="store_true",
76
76
  help="print the URL instead of opening it")
77
+ # What the packaged app does, available from a checkout. Without it a
78
+ # checkout can only ever show the tool as a tab, and the window is the one
79
+ # part of the product nobody sees until a build ships.
80
+ u.add_argument("--window", action="store_true",
81
+ help="open in its own window (needs the [app] extra); "
82
+ "falls back to a browser where there is no webview")
77
83
 
78
84
  i = sub.add_parser("init", help="read a tree and write the spec it already follows")
79
85
  i.add_argument("root")
@@ -187,7 +193,8 @@ def _ui(args) -> int:
187
193
  if root and not root.is_dir():
188
194
  print(f"error: not a folder: {root}", file=sys.stderr)
189
195
  return 2
190
- serve(root, port=args.port, open_browser=not args.no_browser)
196
+ serve(root, port=args.port, open_browser=not args.no_browser,
197
+ shell=args.window and not args.no_browser)
191
198
  return 0
192
199
 
193
200
 
@@ -110,10 +110,15 @@ class Ledger:
110
110
  weighed: dict = field(default_factory=dict)
111
111
  missing: list = field(default_factory=list) # Declared, nothing arrived
112
112
  undeclared: list = field(default_factory=list) # arrived, nothing declared
113
+ # Declared, and here under a name the rule could not read. Not missing:
114
+ # telling a camera crew they never delivered a session that is sitting
115
+ # on the drive with a typo in it is the one mistake this screen must not
116
+ # make. key -> the files that name it.
117
+ misnamed: dict = field(default_factory=dict)
113
118
 
114
119
  @property
115
120
  def complete(self) -> bool:
116
- return not self.missing
121
+ return not self.missing and not self.misnamed
117
122
 
118
123
  @property
119
124
  def arrived(self) -> int:
@@ -317,6 +322,7 @@ class Ledger:
317
322
  # job, only its holes.
318
323
  "roster": [{"label": d.label, "fields": d.fields, "row": d.row,
319
324
  "files": self.delivered.get(d.key, 0),
325
+ "misnamed": len(self.misnamed.get(d.key, ())),
320
326
  "who": self.whose(d),
321
327
  # Whether the job has got this far. A row nothing
322
328
  # arrived for reads as a hole or as work still to
@@ -338,6 +344,10 @@ class Ledger:
338
344
  "where": where_it_belongs(d, self.folders,
339
345
  self.tokens, self.settled)}
340
346
  for d in self.missing],
347
+ "misnamed": [{"label": d.label, "fields": d.fields, "row": d.row,
348
+ "who": self.whose(d),
349
+ "paths": self.misnamed[d.key][:5]}
350
+ for d in self.declared if d.key in self.misnamed],
341
351
  "undeclared": [{"label": " ".join(f"{k}={v}" for k, v in dict(
342
352
  zip(self.key_tokens, key)).items()), "files": self.delivered[key]}
343
353
  for key in self.undeclared],
@@ -660,16 +670,44 @@ def reconcile(declared: list[Declared], records, key_tokens: list,
660
670
  seen["rows"].add(key)
661
671
 
662
672
  declared_keys = {d.key for d in declared}
673
+ unread = [r for r in records
674
+ if not all(t in r.tokens for t in key_tokens)]
663
675
  for entry in declared:
664
676
  if not ledger.delivered.get(entry.key):
665
677
  ledger.delivered.setdefault(entry.key, 0)
666
- ledger.missing.append(entry)
678
+ named = _named_anyway(entry, unread, key_tokens)
679
+ if named:
680
+ ledger.misnamed[entry.key] = named
681
+ else:
682
+ ledger.missing.append(entry)
667
683
 
668
684
  ledger.undeclared = sorted(k for k, n in ledger.delivered.items()
669
685
  if n and k not in declared_keys)
670
686
  return ledger
671
687
 
672
688
 
689
+ def _named_anyway(entry: "Declared", records, key_tokens: list) -> list[str]:
690
+ """Files the rule could not read that still name this declared row.
691
+
692
+ Conservative on purpose: every key value must appear in the path as a
693
+ whole word (`S003`, `D01`, `EXC26`), or have been read from it already.
694
+ `MAINSTAGE_S003` counts for S003; `S0031` does not, and a title alone
695
+ never does, because a near miss here would hide a real hole.
696
+ """
697
+ import re as _re
698
+
699
+ wanted = [str(entry.fields.get(t, "")).lower() for t in key_tokens]
700
+ if not all(wanted):
701
+ return []
702
+ out = []
703
+ for record in records:
704
+ words = set(w.lower() for w in _re.split(r"[^A-Za-z0-9]+", str(record.rel or "")) if w)
705
+ have = {str(v).lower() for v in (record.tokens or {}).values()}
706
+ if all(v in words or v in have for v in wanted):
707
+ out.append(str(record.rel))
708
+ return out
709
+
710
+
673
711
  def findings(ledger: Ledger, severities) -> list[Finding]:
674
712
  """A missing reel is an error in the report, not a note on a side screen.
675
713
 
@@ -693,7 +731,8 @@ def findings(ledger: Ledger, severities) -> list[Finding]:
693
731
  rule="declared-never-delivered",
694
732
  severity=severities("declared-never-delivered", "error"),
695
733
  path=entry.label or "(unnamed)", context=ledger.source,
696
- message=(f"{entry.label} is listed in {ledger.source or 'the manifest'} "
734
+ message=(f"{' '.join(str(v) for v in entry.fields.values()) or entry.label} "
735
+ f"is on {'the running order' if ledger.source == 'schedule' else (ledger.source or 'the manifest')} "
697
736
  f"(row {entry.row}) and nothing was ever delivered for it"),
698
737
  detail={"source": ledger.source, "row": entry.row,
699
738
  "fields": dict(entry.fields)},
@@ -704,8 +743,8 @@ def findings(ledger: Ledger, severities) -> list[Finding]:
704
743
  rule="delivered-never-declared",
705
744
  severity=severities("delivered-never-declared", "warning"),
706
745
  path=described, context=ledger.source,
707
- message=(f"{described} was delivered but is not listed in "
708
- f"{ledger.source or 'the manifest'}"),
746
+ message=(f"{' '.join(str(v) for v in key)} was delivered but is not on "
747
+ f"{'the running order' if ledger.source == 'schedule' else (ledger.source or 'the manifest')}"),
709
748
  detail={"source": ledger.source, "files": ledger.delivered[key]},
710
749
  ))
711
750
  return out
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
  from .say import count
33
33
 
34
34
  import copy
35
+ import json
35
36
  import re
36
37
  import uuid
37
38
  from datetime import datetime, timezone
@@ -512,6 +513,25 @@ def summaries() -> list[dict]:
512
513
  } for r in all_of_them()]
513
514
 
514
515
 
516
+ def identical(name: str, doc: dict) -> dict | None:
517
+ """A convention already kept under this name with the same rules, if any.
518
+
519
+ Compared without the revision and the date, which are what saving
520
+ changes, so the same document kept twice is recognised as the same.
521
+ """
522
+ def bare(d):
523
+ d = dict(d or {})
524
+ d.pop("revision", None)
525
+ d.pop("updated", None)
526
+ return json.dumps(d, sort_keys=True, default=str)
527
+
528
+ wanted = (str(name or "").strip()[:MAX_NAME] or "Untitled convention", bare(doc))
529
+ for row in all_of_them():
530
+ if (row.get("name"), bare(row.get("doc"))) == wanted:
531
+ return row
532
+ return None
533
+
534
+
515
535
  def get(cid: str) -> dict | None:
516
536
  return store._read_json(_file(cid), None)
517
537
 
@@ -605,7 +605,10 @@ def check_packages(packages: dict, records: list[FileRecord],
605
605
  rule="incomplete-package",
606
606
  severity=_shortfall_severity(requirement, severities),
607
607
  path=described or name, context=name,
608
- message=_shortfall(label, described, context, count, rule),
608
+ # Said the way a person says it: "Session package EXC26
609
+ # D01 S005", not "project=EXC26 day=D01 session=S005".
610
+ message=_shortfall(label, " ".join(str(v) for v in key),
611
+ context, count, rule),
609
612
  detail={"package": name, "key": described,
610
613
  "context": context, "found": count,
611
614
  "wanted": _describe(rule)},
@@ -0,0 +1,160 @@
1
+ """A sample drive, for somebody who has not got one to hand.
2
+
3
+ A conference: two days, four rooms, a keyed running order, nine suppliers
4
+ on six roads, some of the job delivered, some of it late, and a handful of
5
+ names off the rule. Every tool has something true to show on it. The files
6
+ are zeros, not media, and the drive says so in its own name.
7
+
8
+ Built into the app's own folder, never anywhere somebody chose, so trying
9
+ the product writes nothing near anybody's work.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from zoneinfo import ZoneInfo
19
+
20
+ import yaml
21
+
22
+ EXAMPLE = Path(__file__).resolve().parent / "demo_convention.yaml"
23
+ TZ = ZoneInfo("Europe/Malta")
24
+
25
+ AGENDA = """\
26
+ Session ID,Session Title,Room,Day,Start,End,Speakers,Sponsor
27
+ S001,Opening Keynote,Main Stage,1,09:00,10:00,Jane Doe;Ravi Patel,
28
+ S002,Scaling Payments,Tech Stage,1,10:30,11:15,Marta Kowalski,Acme Corp
29
+ S003,Regulation Panel,Main Stage,1,11:30,12:30,Jane Doe,
30
+ S004,AI in Compliance,Tech Stage,1,13:00,13:45,Sam Okoro,
31
+ S005,Colour Pipelines,Workshop Room,1,10:00,11:00,Elena Nowak,
32
+ S006,Founder Interviews,Interview Studio,1,12:00,13:00,Leo Marsh,Northwind
33
+ S007,Closing Remarks,Main Stage,1,16:00,16:30,Jane Doe,
34
+ S011,Day Two Keynote,Main Stage,2,09:00,10:00,Ravi Patel,Northwind
35
+ S012,Payments at the Edge,Tech Stage,2,10:30,11:15,Marta Kowalski,Acme Corp
36
+ S013,Audio for Streams,Workshop Room,2,10:00,11:00,Felix Silva,
37
+ S014,Investor Panel,Main Stage,2,11:30,12:30,Dana Okafor,
38
+ S015,Creator Interviews,Interview Studio,2,12:00,13:00,Leo Marsh,
39
+ S016,Security Deep Dive,Tech Stage,2,13:30,14:15,Sam Okoro,
40
+ S017,Awards,Main Stage,2,17:00,18:00,Jane Doe,Acme Corp
41
+ """
42
+
43
+ VENDORS = {
44
+ "crew_main": {"label": "Hall 1 camera crew", "contact": "hall1@lisbonbroadcast.example",
45
+ "owns": ["iso_record"], "where": {"stage": "MAIN"},
46
+ "via": "dailiesdrop", "unit": "DD-PRO-0117"},
47
+ "crew_tech": {"label": "Hall 2 camera crew", "contact": "hall2@lisbonbroadcast.example",
48
+ "owns": ["iso_record"], "where": {"stage": "TECH"},
49
+ "via": "dailiesdrop", "unit": "DD-PRO-0117"},
50
+ "crew_wksp": {"label": "Workshop crew", "contact": "wksp@lisbonbroadcast.example",
51
+ "owns": ["iso_record"], "where": {"stage": "WKSP"},
52
+ "via": "dailiesdrop", "unit": "DD-PRO-0122"},
53
+ "crew_stdo": {"label": "Studio crew", "contact": "studio@lisbonbroadcast.example",
54
+ "owns": ["iso_record"], "where": {"stage": "STDO"},
55
+ "via": "shuttle"},
56
+ "master_edit": {"label": "Tejo Post", "contact": "deliveries@tejopost.example",
57
+ "due": "2026-11-13", "owns": ["session_master"], "via": "dailiesdrop",
58
+ "unit": "DD-PRO-0122"},
59
+ "social_edit": {"label": "Northlight Edit", "contact": "deliveries@northlight.example",
60
+ "due": "2026-11-14", "owns": ["social_cutdown"], "via": "frameio"},
61
+ "sponsor_agency": {"label": "Meridian Sponsorship", "contact": "post@meridian.example",
62
+ "due": "2026-11-18", "owns": ["sponsor_deliverable"],
63
+ "via": "wetransfer"},
64
+ "captions": {"label": "Verbatim Captioning", "contact": "ops@verbatim.example",
65
+ "due": "2026-11-16", "owns": ["speaker_deliverable"], "via": "gdrive"},
66
+ "proxy_house": {"label": "Lisbon Proxy", "contact": "ops@lisbonproxy.example",
67
+ "owns": ["proxy"], "via": "aspera"},
68
+ }
69
+
70
+ M = "EXC26/D0{d}/{st}/MASTERS/EXC26_D0{d}_{st}_{s}_{t}_v01.mov"
71
+
72
+ FILES = [
73
+ # Day one masters, mostly right
74
+ (M.format(d=1, st="MAIN", s="S001", t="opening-keynote"), 1, "09:05"),
75
+ (M.format(d=1, st="TECH", s="S002", t="scaling-payments"), 1, "10:35"),
76
+ (M.format(d=1, st="WKSP", s="S005", t="colour-pipelines"), 1, "10:05"),
77
+ (M.format(d=1, st="STDO", s="S006", t="founder-interviews"), 1, "12:05"),
78
+ (M.format(d=1, st="MAIN", s="S007", t="closing-remarks"), 1, "16:05"),
79
+ # Off the rule
80
+ ("EXC26/D01/MAIN/MASTERS/EXC26_D01_MAINSTAGE_S003_regulation-panel_v01.mov", 1, "11:35"),
81
+ ("EXC26/D01/TECH/MASTERS/EXC26_D01_TECH_S004_ai-in-compliance.mov", 1, "13:05"),
82
+ ("EXC26/D01/MAIN/MASTERS/exc26 d01 main s007 closing remarks FINAL.mov", 1, "16:10"),
83
+ ("EXC26/D01/MAIN/MASTERS/EXC26_D01_MAIN_S009_overnight-test_v01.mov", 1, "03:00"),
84
+ ("EXC26/D01/handover-notes.txt", None, None),
85
+ # Day two, half in
86
+ (M.format(d=2, st="MAIN", s="S011", t="day-two-keynote"), 2, "09:05"),
87
+ (M.format(d=2, st="TECH", s="S012", t="payments-at-the-edge"), 2, "10:35"),
88
+ (M.format(d=2, st="WKSP", s="S013", t="audio-for-streams"), 2, "10:05"),
89
+ ("EXC26/D02/MAIN/MASTERS/Investor Panel MASTER.mov", 2, "11:35"),
90
+ # Recorder originals, never renamed
91
+ ("EXC26/D01/MAIN/ISO/CAM01/A001C001_261110_ABCD.mov", 1, "09:05"),
92
+ ("EXC26/D01/MAIN/ISO/CAM02/A002C001_261110_ABCD.mov", 1, "09:05"),
93
+ ("EXC26/D01/TECH/ISO/CAM01/B001C004_261110_EF01.mov", 1, "10:35"),
94
+ ("EXC26/D01/WKSP/ISO/CAM01/C001C002_261110_7A21.mov", 1, "10:05"),
95
+ ("EXC26/D02/MAIN/ISO/CAM01/A001C031_261111_ABCD.mov", 2, "09:05"),
96
+ ("EXC26/D02/TECH/ISO/CAM01/B001C022_261111_EF01.mov", 2, "10:35"),
97
+ # Proxies
98
+ ("EXC26/PROXY/D01/MAIN/EXC26_D01_MAIN_S001_CAM01_proxy.mp4", None, None),
99
+ ("EXC26/PROXY/D01/TECH/EXC26_D01_TECH_S002_CAM01_proxy.mp4", None, None),
100
+ ("EXC26/PROXY/D01/MAIN/S003 cam1 proxy.mp4", None, None),
101
+ # Deliverables
102
+ ("EXC26/DELIVERABLES/SPEAKERS/D01/EXC26_D01_S001_doe-jane_opening-keynote_v01.mp4", None, None),
103
+ ("EXC26/DELIVERABLES/SPEAKERS/D01/EXC26_D01_S002_kowalski-marta_scaling-payments_v01.mp4", None, None),
104
+ ("EXC26/DELIVERABLES/SPONSORS/acme-corp/EXC26_acme-corp_D01_S002_scaling-payments_16x9_v01.mp4", None, None),
105
+ ("EXC26/DELIVERABLES/SPONSORS/northwind/northwind cut v2.mp4", None, None),
106
+ ("EXC26/SOCIAL/D01/EXC26_S001_opening-keynote_9x16_v01.mp4", None, None),
107
+ ("EXC26/SOCIAL/D01/EXC26_S002_scaling-payments_1x1_v01.mp4", None, None),
108
+ ]
109
+
110
+
111
+ def build(root: Path) -> Path:
112
+ """Lay the sample out under `root`; returns the convention it carries."""
113
+ root = Path(root)
114
+ spec_dir = root / "EXC26" / "_SPEC"
115
+ spec_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ doc = yaml.safe_load(EXAMPLE.read_text(encoding="utf-8"))
118
+ # The files are zeros, not media: delivery rules would only report that.
119
+ doc.pop("deliverables", None)
120
+ for ctx in doc["contexts"]:
121
+ ctx.pop("deliverable", None)
122
+ doc["sources"]["schedule"]["align"]["clock"] = "file_mtime"
123
+ doc["id"] = "showcase-conference"
124
+ doc["name"] = "Example Conference 2026 - Live Video"
125
+ doc["vendors"] = VENDORS
126
+ (spec_dir / "spec.yaml").write_text(
127
+ yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), encoding="utf-8"
128
+ )
129
+ (spec_dir / "agenda.csv").write_text(AGENDA, encoding="utf-8")
130
+
131
+ for rel, day, hhmm in FILES:
132
+ path = root / rel
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ size = 4096 if rel.endswith((".mov", ".mxf")) else 1024
135
+ path.write_bytes(b"\0" * size)
136
+ if hhmm:
137
+ h, m = (int(x) for x in hhmm.split(":"))
138
+ when = datetime(2026, 11, 9 + day, h, m, tzinfo=TZ).timestamp()
139
+ os.utime(path, (when, when))
140
+ return spec_dir / "spec.yaml"
141
+
142
+
143
+
144
+
145
+ def make(home: Path, *, fresh: bool = False) -> Path:
146
+ """The sample drive in the app's folder, built once and kept.
147
+
148
+ `fresh` throws the old one away first, so somebody who renamed half of
149
+ it can start again. Only ever the folder this module made.
150
+ """
151
+ target = Path(home) / "sample-drive"
152
+ if fresh and target.is_dir() and (target / ".shuttlecheck-sample").is_file():
153
+ shutil.rmtree(target)
154
+ if not (target / ".shuttlecheck-sample").is_file():
155
+ target.mkdir(parents=True, exist_ok=True)
156
+ build(target)
157
+ (target / ".shuttlecheck-sample").write_text(
158
+ "A sample drive made by ShuttleCheck. Safe to delete.\n",
159
+ encoding="utf-8")
160
+ return target / "EXC26"