shuttlecheck 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,39 @@
1
+ """ShuttleCheck -- naming and delivery conformance.
2
+
3
+ Three entry points, in the order you would use them:
4
+
5
+ infer_spec / walkthrough read an existing tree and write the spec it follows
6
+ Validator / validate report what does not conform, in name and in
7
+ substance -- codec, resolution, audio, packages
8
+ verify check a drive against the sidecar it arrived with
9
+
10
+ **Nothing in this package modifies media.** That is the whole of it, and it is
11
+ deliberate: a checker that cannot alter anything is a checker a stranger will
12
+ run on a live job, because running it cannot cost them anything.
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
16
+ installed, `extensions.load()` finds it and the CLI and app grow the extra
17
+ commands; nothing here imports it, and nothing here requires it.
18
+ """
19
+
20
+ from . import extensions
21
+ from .delivery import (FFProbeProber, MediaInfo, NullProber, Prober,
22
+ check_deliverable, check_packages, check_sidecars)
23
+ from .infer import InferenceReport, infer_spec
24
+ from .report import Finding, Report
25
+ from .sidecar import (ManifestEntry, Sidecar, SidecarError, read_sidecar,
26
+ render_verify, verify)
27
+ from .spec import Spec, SpecError, check_document, load_spec
28
+ from .validate import Validator, validate
29
+ from .version import __version__
30
+ from .walkthrough import present, questionnaire, render_spec_yaml, walkthrough
31
+
32
+ __all__ = [
33
+ "Finding", "Report", "Spec", "SpecError", "load_spec", "check_document",
34
+ "Validator", "validate", "InferenceReport", "infer_spec", "walkthrough",
35
+ "present", "render_spec_yaml", "questionnaire", "__version__", "extensions",
36
+ "Sidecar", "SidecarError", "ManifestEntry", "verify", "read_sidecar",
37
+ "render_verify", "Prober", "FFProbeProber", "NullProber", "MediaInfo",
38
+ "check_deliverable", "check_packages", "check_sidecars",
39
+ ]
shuttlecheck/checks.py ADDED
@@ -0,0 +1,156 @@
1
+ """Portability, structure, and collision checks.
2
+
3
+ Each of these is a real-world failure that looks like a bug in someone else's
4
+ software when it happens: a drive that ingests on macOS and fails on Windows,
5
+ a conform that cannot relink, a copy that silently drops a file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import unicodedata
12
+ from pathlib import Path
13
+
14
+ from .report import Finding
15
+
16
+ RESERVED_RANGE = re.compile(r"^([A-Z]+)(\d)-(\d)$")
17
+
18
+
19
+ def expand_reserved(names) -> set[str]:
20
+ """Expand 'COM1-9' into COM1..COM9."""
21
+ out: set[str] = set()
22
+ for n in names or []:
23
+ m = RESERVED_RANGE.fullmatch(str(n).upper())
24
+ if m:
25
+ stem, lo, hi = m.group(1), int(m.group(2)), int(m.group(3))
26
+ out.update(f"{stem}{i}" for i in range(lo, hi + 1))
27
+ else:
28
+ out.add(str(n).upper())
29
+ return out
30
+
31
+
32
+ def check_portability(spec, root: Path, path: Path, rel: str) -> list[Finding]:
33
+ """Cross-platform name and path constraints for one file or directory."""
34
+ p = spec.portability
35
+ findings: list[Finding] = []
36
+ name = path.name
37
+ stem = path.stem
38
+
39
+ forbidden = set(p.get("forbidden_chars") or "") - {"/", "\\", ":"}
40
+ hit = sorted(set(name) & forbidden)
41
+ if hit:
42
+ findings.append(Finding(
43
+ rule="forbidden-char",
44
+ severity=spec.severity("forbidden-char", "error"),
45
+ path=rel,
46
+ message=("name contains characters that are illegal on "
47
+ f"{'/'.join(p['targets'])}: {' '.join(repr(c) for c in hit)}"),
48
+ ))
49
+
50
+ if stem.upper() in expand_reserved(p.get("reserved_names")):
51
+ findings.append(Finding(
52
+ rule="reserved-name",
53
+ severity=spec.severity("reserved-name", "error"),
54
+ path=rel,
55
+ message=f"'{stem}' is a reserved device name on Windows and cannot be opened",
56
+ ))
57
+
58
+ for ch in p.get("no_trailing") or []:
59
+ if name.endswith(ch):
60
+ findings.append(Finding(
61
+ rule="trailing-char",
62
+ severity=spec.severity("trailing-char", "error"),
63
+ path=rel,
64
+ message=(f"name ends with {ch!r}; Windows strips it silently, so the "
65
+ "name on disk will stop matching the name in your manifest"),
66
+ ))
67
+ break
68
+
69
+ max_name = p.get("max_filename")
70
+ if max_name and len(name) > max_name:
71
+ findings.append(Finding(
72
+ rule="filename-too-long",
73
+ severity=spec.severity("filename-too-long", "error"),
74
+ path=rel,
75
+ message=f"filename is {len(name)} characters, limit is {max_name}",
76
+ ))
77
+
78
+ max_path = p.get("max_path")
79
+ if max_path and "windows" in p.get("targets", []):
80
+ absolute = len(str(path.resolve()))
81
+ if absolute > max_path:
82
+ findings.append(Finding(
83
+ rule="path-too-long",
84
+ severity=spec.severity("path-too-long", "error"),
85
+ path=rel,
86
+ message=(f"absolute path is {absolute} characters, over the "
87
+ f"{max_path}-character Windows limit"),
88
+ detail={"absolute_length": absolute},
89
+ ))
90
+
91
+ if p.get("space_policy") == "forbid" and " " in name:
92
+ findings.append(Finding(
93
+ rule="space-in-name",
94
+ severity=spec.severity("space-in-name", "warning"),
95
+ path=rel,
96
+ message="name contains a space and the spec forbids spaces",
97
+ ))
98
+
99
+ if spec.safety.get("unicode") == "nfc" and unicodedata.normalize("NFC", name) != name:
100
+ findings.append(Finding(
101
+ rule="unicode-normalisation",
102
+ severity=spec.severity("unicode-normalisation", "warning"),
103
+ path=rel,
104
+ message=("name is not NFC-normalised; macOS stores decomposed and Windows "
105
+ "composed, so this name compares unequal across platforms"),
106
+ ))
107
+
108
+ return findings
109
+
110
+
111
+ def check_case_collisions(spec, entries: dict[Path, list[str]]) -> list[Finding]:
112
+ """Sibling names differing only by case break on Windows and macOS."""
113
+ findings: list[Finding] = []
114
+ for parent, names in entries.items():
115
+ buckets: dict[str, list[str]] = {}
116
+ for n in names:
117
+ buckets.setdefault(n.lower(), []).append(n)
118
+ for lowered, group in buckets.items():
119
+ if len(group) > 1:
120
+ findings.append(Finding(
121
+ rule="case-collision",
122
+ severity=spec.severity("case-collision", "error"),
123
+ path=str(parent),
124
+ message=("names differ only by case and cannot coexist on a "
125
+ f"case-insensitive volume: {', '.join(sorted(group))}"),
126
+ detail={"names": sorted(group)},
127
+ ))
128
+ return findings
129
+
130
+
131
+ def check_structure(spec, dirs: list[str], files_by_dir: dict[str, list[str]]) -> list[Finding]:
132
+ """Required folders present, and no loose files where they are disallowed."""
133
+ findings: list[Finding] = []
134
+
135
+ for pattern, regex in spec.structure.get("_required_re", []):
136
+ if not any(regex.fullmatch(d) for d in dirs):
137
+ findings.append(Finding(
138
+ rule="missing-folder",
139
+ severity=spec.severity("missing-folder", "warning"),
140
+ path=pattern,
141
+ message="no folder in the tree matches this required structure path",
142
+ ))
143
+
144
+ for pattern, regex in spec.structure.get("_loose_re", []):
145
+ for d, names in files_by_dir.items():
146
+ if regex.fullmatch(d) and names:
147
+ findings.append(Finding(
148
+ rule="loose-file",
149
+ severity=spec.severity("loose-file", "warning"),
150
+ path=d or ".",
151
+ message=(f"{len(names)} file(s) sit directly in a folder that should "
152
+ f"contain only subfolders: {', '.join(sorted(names)[:4])}"
153
+ + (" ..." if len(names) > 4 else "")),
154
+ detail={"files": sorted(names)},
155
+ ))
156
+ return findings
shuttlecheck/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ """Command line entry point.
2
+
3
+ shuttlecheck ui [folder] open the local app in a browser
4
+ shuttlecheck init <folder> read the convention off an existing tree
5
+ shuttlecheck validate <folder> --spec S report what does not conform
6
+ shuttlecheck verify <folder> check a drive that arrived (no spec needed)
7
+ shuttlecheck check-spec --spec S validate the spec itself
8
+
9
+ None of the above modifies media. That is the whole of this package, and it is
10
+ deliberate: a checker that cannot alter anything is a checker a stranger will
11
+ run on a live job.
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
15
+ it is absent those names are still registered, so asking for one gets an
16
+ explanation rather than "invalid choice".
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+ from . import extensions
28
+ from .report import Report
29
+ from .sidecar import SidecarError, render_verify, verify
30
+ from .spec import SpecError, load_spec
31
+ from .ui import serve
32
+ from .validate import Validator
33
+ from .version import __version__
34
+ from .walkthrough import present, render_spec_yaml, walkthrough
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ p = argparse.ArgumentParser(
39
+ prog="shuttlecheck",
40
+ description="Naming and delivery conformance for live video and film.")
41
+ p.add_argument("--version", action="version", version=f"shuttlecheck {__version__}")
42
+ sub = p.add_subparsers(dest="command", required=True)
43
+
44
+ v = sub.add_parser("validate", help="check a tree and report violations")
45
+ v.add_argument("root")
46
+ v.add_argument("--spec", required=True)
47
+ v.add_argument("--json", action="store_true", help="emit JSON instead of text")
48
+ v.add_argument("--out", help="also write the report to this file")
49
+ v.add_argument("--info", action="store_true", help="include info-level findings")
50
+ v.add_argument("--limit", type=int, default=200,
51
+ help="max findings to print (0 for all)")
52
+ v.add_argument("--strict", action="store_true",
53
+ help="exit non-zero on warnings as well as errors")
54
+
55
+ c = sub.add_parser("check-spec", help="validate the spec itself and exit")
56
+ c.add_argument("--spec", required=True)
57
+
58
+ u = sub.add_parser("ui", help="open the local app in a browser")
59
+ u.add_argument("root", nargs="?", help="folder to open on (optional)")
60
+ u.add_argument("--port", type=int, default=8765)
61
+ u.add_argument("--no-browser", action="store_true",
62
+ help="print the URL instead of opening it")
63
+
64
+ i = sub.add_parser("init", help="read a tree and write the spec it already follows")
65
+ i.add_argument("root")
66
+ i.add_argument("--out", help="default: <root>/_SPEC/spec.yaml")
67
+ i.add_argument("--project-code")
68
+ i.add_argument("--vocabulary", choices=["events", "film"], default="events")
69
+ i.add_argument("--id", dest="spec_id")
70
+ i.add_argument("--yes", action="store_true", help="accept every inference")
71
+ i.add_argument("--force", action="store_true", help="overwrite an existing spec")
72
+
73
+ y = sub.add_parser(
74
+ "verify", help="check a drive against the sidecar it arrived with")
75
+ y.add_argument("root")
76
+ y.add_argument("--hash", action="store_true",
77
+ help="verify contents, not just sizes (slow)")
78
+ y.add_argument("--no-naming", action="store_true",
79
+ help="check arrival only, skip the naming check")
80
+ y.add_argument("--limit", type=int, default=60)
81
+
82
+ pro = extensions.load()
83
+ if pro is not None:
84
+ pro.add_commands(sub)
85
+ else:
86
+ _add_licensed_stubs(sub)
87
+
88
+ return p
89
+
90
+
91
+ LICENSED = ("conform", "revert", "history", "seal", "pack", "vendors")
92
+
93
+
94
+ def _add_licensed_stubs(sub) -> None:
95
+ """Register the Pro command names so they explain themselves.
96
+
97
+ argparse would otherwise answer `shuttlecheck conform` with "invalid
98
+ choice", which reads like the command was misspelt rather than not
99
+ installed. These accept anything and fail with a sentence.
100
+ """
101
+ for name in LICENSED:
102
+ stub = sub.add_parser(name, help="(ShuttleCheck Pro -- not installed)",
103
+ add_help=False)
104
+ stub.add_argument("args", nargs=argparse.REMAINDER)
105
+
106
+
107
+ def _licensed_stub(args) -> int:
108
+ try:
109
+ extensions.require(f"`shuttlecheck {args.command}`")
110
+ except extensions.ExtensionMissing as exc:
111
+ print(f"\n{exc}\n", file=sys.stderr)
112
+ return 2
113
+ return 2
114
+
115
+
116
+ def _load(spec_path: str):
117
+ try:
118
+ return load_spec(spec_path)
119
+ except SpecError as exc:
120
+ print(f"error: {exc}", file=sys.stderr)
121
+ raise SystemExit(2)
122
+
123
+
124
+ def _require_dir(root: str) -> Path:
125
+ path = Path(root)
126
+ if not path.is_dir():
127
+ print(f"error: not a folder: {path}", file=sys.stderr)
128
+ raise SystemExit(2)
129
+ return path
130
+
131
+
132
+ # --------------------------------------------------------------------------
133
+
134
+ def _validate(args) -> int:
135
+ spec = _load(args.spec)
136
+ root = _require_dir(args.root)
137
+ report: Report = Validator(spec, root).run()
138
+ text = (report.to_json() if args.json
139
+ else report.to_text(show_info=args.info, limit=args.limit))
140
+ print(text)
141
+ if args.out:
142
+ Path(args.out).write_text(text, encoding="utf-8")
143
+ if report.errors:
144
+ return 1
145
+ return 1 if args.strict and report.warnings else 0
146
+
147
+
148
+ def _check_spec(args) -> int:
149
+ spec = _load(args.spec)
150
+ if spec.lint:
151
+ for f in spec.lint:
152
+ print(f"{f.severity}: {f.rule}: {f.message}", file=sys.stderr)
153
+ return 1 if any(f.severity == "error" for f in spec.lint) else 0
154
+ print(f"{args.spec}: valid ({len(spec.tokens)} tokens, {len(spec.contexts)} contexts)")
155
+ return 0
156
+
157
+
158
+ def _ui(args) -> int:
159
+ root = Path(args.root).resolve() if args.root else None
160
+ if root and not root.is_dir():
161
+ print(f"error: not a folder: {root}", file=sys.stderr)
162
+ return 2
163
+ serve(root, port=args.port, open_browser=not args.no_browser)
164
+ return 0
165
+
166
+
167
+ def _init(args) -> int:
168
+ root = _require_dir(args.root)
169
+ out = Path(args.out) if args.out else root / "_SPEC" / "spec.yaml"
170
+ if out.exists() and not args.force:
171
+ print(f"error: {out} already exists; pass --force to overwrite", file=sys.stderr)
172
+ return 2
173
+ try:
174
+ doc, report, validation = walkthrough(
175
+ root, project_code=args.project_code, vocabulary=args.vocabulary,
176
+ interactive=not args.yes, spec_id=args.spec_id)
177
+ except ValueError as exc:
178
+ print(f"error: {exc}", file=sys.stderr)
179
+ return 2
180
+
181
+ if report is not None:
182
+ if args.yes:
183
+ print(present(report, validation))
184
+ text = render_spec_yaml(doc, report)
185
+ else:
186
+ text = yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100)
187
+
188
+ out.parent.mkdir(parents=True, exist_ok=True)
189
+ out.write_text(text, encoding="utf-8")
190
+ print(f"\n spec written to {out}")
191
+ try:
192
+ load_spec(out)
193
+ except SpecError as exc:
194
+ print(f"\nwarning: the inferred spec does not match the schema:\n{exc}",
195
+ file=sys.stderr)
196
+ return 1
197
+ if report is not None and report.needs_review:
198
+ print(f" {len(report.needs_review)} item(s) marked for review in the header.")
199
+ print(f"\n next: shuttlecheck validate {root} --spec {out}")
200
+ return 0
201
+
202
+
203
+ def _verify(args) -> int:
204
+ root = _require_dir(args.root)
205
+ try:
206
+ result = verify(root, check_hashes=args.hash,
207
+ check_naming=not args.no_naming)
208
+ except SidecarError as exc:
209
+ print(f"error: {exc}", file=sys.stderr)
210
+ return 2
211
+ print(render_verify(result, limit=args.limit))
212
+ if not result.intact:
213
+ return 1
214
+ return 1 if result.naming is not None and result.naming.errors else 0
215
+
216
+
217
+ COMMANDS = {"ui": _ui, "validate": _validate, "check-spec": _check_spec,
218
+ "init": _init, "verify": _verify}
219
+
220
+
221
+ def _handlers() -> dict:
222
+ handlers = dict(COMMANDS)
223
+ pro = extensions.load()
224
+ if pro is not None:
225
+ handlers.update(pro.COMMANDS)
226
+ else:
227
+ handlers.update({name: _licensed_stub for name in LICENSED})
228
+ return handlers
229
+
230
+
231
+ def main(argv=None) -> int:
232
+ args = build_parser().parse_args(argv)
233
+ try:
234
+ return _handlers()[args.command](args)
235
+ except SystemExit as exc:
236
+ return int(exc.code or 0)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ raise SystemExit(main())