c4py 1.0.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.
- c4py/__init__.py +95 -0
- c4py/__main__.py +228 -0
- c4py/decoder.py +667 -0
- c4py/diff.py +632 -0
- c4py/encoder.py +211 -0
- c4py/entry.py +429 -0
- c4py/id.py +269 -0
- c4py/manifest.py +375 -0
- c4py/naturalsort.py +88 -0
- c4py/pool.py +243 -0
- c4py/py.typed +0 -0
- c4py/reconcile.py +319 -0
- c4py/safename.py +286 -0
- c4py/scanner.py +393 -0
- c4py/store.py +237 -0
- c4py/validator.py +362 -0
- c4py/verify.py +136 -0
- c4py/workspace.py +206 -0
- c4py-1.0.0.dist-info/METADATA +215 -0
- c4py-1.0.0.dist-info/RECORD +23 -0
- c4py-1.0.0.dist-info/WHEEL +4 -0
- c4py-1.0.0.dist-info/entry_points.txt +2 -0
- c4py-1.0.0.dist-info/licenses/LICENSE +191 -0
c4py/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""c4py — Pure Python C4 universal content identification (SMPTE ST 2114:2017).
|
|
2
|
+
|
|
3
|
+
import c4py
|
|
4
|
+
|
|
5
|
+
# Identify content
|
|
6
|
+
c4id = c4py.identify(open("file.mov", "rb"))
|
|
7
|
+
c4id = c4py.identify_bytes(b"hello")
|
|
8
|
+
c4id = c4py.parse("c45xZeXwMSpq...")
|
|
9
|
+
|
|
10
|
+
# C4M manifests
|
|
11
|
+
manifest = c4py.scan("/projects/HERO")
|
|
12
|
+
manifest = c4py.load("project.c4m")
|
|
13
|
+
diff = c4py.diff(old, new)
|
|
14
|
+
|
|
15
|
+
# Content store (shared with c4 CLI and c4sh)
|
|
16
|
+
store = c4py.open_store("~/.c4/store")
|
|
17
|
+
c4id = store.put(open("render.exr", "rb"))
|
|
18
|
+
content = store.get(c4id)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .id import C4ID, identify, identify_bytes, identify_file, identify_files, verify, parse, tree_id
|
|
24
|
+
from .manifest import Manifest
|
|
25
|
+
from .entry import Entry
|
|
26
|
+
from .diff import (
|
|
27
|
+
Conflict, DiffResult, PatchInfo,
|
|
28
|
+
diff, patch_diff, apply_patch, resolve_chain, log_chain, merge,
|
|
29
|
+
)
|
|
30
|
+
from .decoder import load, loads
|
|
31
|
+
from .encoder import dump, dumps
|
|
32
|
+
from .scanner import scan
|
|
33
|
+
from .store import ContentNotFound, FSStore, Store, open_store
|
|
34
|
+
from .reconcile import ReconcileOp, ReconcilePlan, ReconcileResult, reconcile
|
|
35
|
+
from .pool import IngestResult, PoolResult, ingest, pool
|
|
36
|
+
from .validator import ValidationResult, validate
|
|
37
|
+
from .verify import CorruptEntry, VerifyReport, verify_tree
|
|
38
|
+
from .workspace import Workspace
|
|
39
|
+
|
|
40
|
+
__version__ = "1.0.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Identification
|
|
44
|
+
"C4ID",
|
|
45
|
+
"identify",
|
|
46
|
+
"identify_bytes",
|
|
47
|
+
"identify_file",
|
|
48
|
+
"identify_files",
|
|
49
|
+
"verify",
|
|
50
|
+
"parse",
|
|
51
|
+
"tree_id",
|
|
52
|
+
# Manifest
|
|
53
|
+
"Manifest",
|
|
54
|
+
"Entry",
|
|
55
|
+
# I/O
|
|
56
|
+
"load",
|
|
57
|
+
"loads",
|
|
58
|
+
"dump",
|
|
59
|
+
"dumps",
|
|
60
|
+
"scan",
|
|
61
|
+
# Content Store
|
|
62
|
+
"Store",
|
|
63
|
+
"FSStore",
|
|
64
|
+
"ContentNotFound",
|
|
65
|
+
"open_store",
|
|
66
|
+
# Operations
|
|
67
|
+
"Conflict",
|
|
68
|
+
"DiffResult",
|
|
69
|
+
"PatchInfo",
|
|
70
|
+
"diff",
|
|
71
|
+
"merge",
|
|
72
|
+
"patch_diff",
|
|
73
|
+
"apply_patch",
|
|
74
|
+
"resolve_chain",
|
|
75
|
+
"log_chain",
|
|
76
|
+
# Reconcile
|
|
77
|
+
"ReconcileOp",
|
|
78
|
+
"ReconcilePlan",
|
|
79
|
+
"ReconcileResult",
|
|
80
|
+
"reconcile",
|
|
81
|
+
# Pool / Ingest
|
|
82
|
+
"PoolResult",
|
|
83
|
+
"IngestResult",
|
|
84
|
+
"pool",
|
|
85
|
+
"ingest",
|
|
86
|
+
# Validation
|
|
87
|
+
"ValidationResult",
|
|
88
|
+
"validate",
|
|
89
|
+
# Verification
|
|
90
|
+
"CorruptEntry",
|
|
91
|
+
"VerifyReport",
|
|
92
|
+
"verify_tree",
|
|
93
|
+
# Workspace
|
|
94
|
+
"Workspace",
|
|
95
|
+
]
|
c4py/__main__.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""CLI entry point for c4py — invoked via `python -m c4py`.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
id <path> Identify file/directory, output c4m
|
|
5
|
+
id -i <path> Bare C4 ID only (no c4m format)
|
|
6
|
+
diff <old> <new> Compare two c4m manifests
|
|
7
|
+
verify <c4m> <dir> Verify directory matches manifest
|
|
8
|
+
cat <c4id> Retrieve content from store
|
|
9
|
+
version Print version
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cmd_id(args: argparse.Namespace) -> int:
|
|
20
|
+
"""Identify a file or directory."""
|
|
21
|
+
from . import __version__ # noqa: F401
|
|
22
|
+
from .id import identify_file
|
|
23
|
+
from .scanner import scan
|
|
24
|
+
from .encoder import dumps
|
|
25
|
+
|
|
26
|
+
target = Path(args.path)
|
|
27
|
+
|
|
28
|
+
if not target.exists():
|
|
29
|
+
print(f"error: {args.path}: no such file or directory", file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
if args.id_only:
|
|
33
|
+
# Bare C4 ID mode
|
|
34
|
+
if target.is_file():
|
|
35
|
+
c4id = identify_file(target)
|
|
36
|
+
print(str(c4id))
|
|
37
|
+
elif target.is_dir():
|
|
38
|
+
manifest = scan(target)
|
|
39
|
+
c4id = manifest.compute_c4id()
|
|
40
|
+
print(str(c4id))
|
|
41
|
+
else:
|
|
42
|
+
print(f"error: {args.path}: unsupported file type", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
else:
|
|
45
|
+
# Full c4m output
|
|
46
|
+
if target.is_file():
|
|
47
|
+
c4id = identify_file(target)
|
|
48
|
+
print(str(c4id))
|
|
49
|
+
elif target.is_dir():
|
|
50
|
+
manifest = scan(target)
|
|
51
|
+
text = dumps(manifest)
|
|
52
|
+
sys.stdout.write(text)
|
|
53
|
+
else:
|
|
54
|
+
print(f"error: {args.path}: unsupported file type", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_diff(args: argparse.Namespace) -> int:
|
|
61
|
+
"""Compare two c4m manifests."""
|
|
62
|
+
from .decoder import load
|
|
63
|
+
from .diff import diff
|
|
64
|
+
|
|
65
|
+
old_path = args.old
|
|
66
|
+
new_path = args.new
|
|
67
|
+
|
|
68
|
+
for p in (old_path, new_path):
|
|
69
|
+
if not Path(p).exists():
|
|
70
|
+
print(f"error: {p}: no such file", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
old_manifest = load(old_path)
|
|
74
|
+
new_manifest = load(new_path)
|
|
75
|
+
result = diff(old_manifest, new_manifest)
|
|
76
|
+
|
|
77
|
+
if result.is_empty:
|
|
78
|
+
print("no differences")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
if result.added:
|
|
82
|
+
for entry in result.added:
|
|
83
|
+
print(f"+ {entry.name}")
|
|
84
|
+
if result.removed:
|
|
85
|
+
for entry in result.removed:
|
|
86
|
+
print(f"- {entry.name}")
|
|
87
|
+
if result.modified:
|
|
88
|
+
for entry in result.modified:
|
|
89
|
+
print(f"~ {entry.name}")
|
|
90
|
+
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
95
|
+
"""Verify a directory matches a manifest."""
|
|
96
|
+
from .verify import verify_tree
|
|
97
|
+
|
|
98
|
+
manifest_path = args.manifest
|
|
99
|
+
directory = args.directory
|
|
100
|
+
|
|
101
|
+
if not Path(manifest_path).exists():
|
|
102
|
+
print(f"error: {manifest_path}: no such file", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
if not Path(directory).is_dir():
|
|
105
|
+
print(f"error: {directory}: not a directory", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
report = verify_tree(manifest_path, directory)
|
|
109
|
+
|
|
110
|
+
if report.is_ok:
|
|
111
|
+
print(f"OK: {len(report.ok)} files verified")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
if report.ok:
|
|
115
|
+
print(f"ok: {len(report.ok)} files match")
|
|
116
|
+
if report.missing:
|
|
117
|
+
print(f"missing: {len(report.missing)} files")
|
|
118
|
+
for p in report.missing:
|
|
119
|
+
print(f" - {p}")
|
|
120
|
+
if report.corrupt:
|
|
121
|
+
print(f"corrupt: {len(report.corrupt)} files")
|
|
122
|
+
for c in report.corrupt:
|
|
123
|
+
print(f" ! {c.path}")
|
|
124
|
+
print(f" expected: {c.expected}")
|
|
125
|
+
print(f" actual: {c.actual}")
|
|
126
|
+
if report.extra:
|
|
127
|
+
print(f"extra: {len(report.extra)} files")
|
|
128
|
+
for p in report.extra:
|
|
129
|
+
print(f" + {p}")
|
|
130
|
+
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd_cat(args: argparse.Namespace) -> int:
|
|
135
|
+
"""Retrieve content from store by C4 ID."""
|
|
136
|
+
from .id import parse
|
|
137
|
+
from .store import ContentNotFound, open_store
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
c4id = parse(args.c4id)
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
print(f"error: invalid C4 ID: {e}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
store = open_store()
|
|
147
|
+
except ValueError as e:
|
|
148
|
+
print(f"error: {e}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
stream = store.get(c4id)
|
|
153
|
+
except ContentNotFound:
|
|
154
|
+
print(f"error: content not found: {args.c4id}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
stdout_bin = sys.stdout.buffer
|
|
159
|
+
while True:
|
|
160
|
+
chunk = stream.read(65536)
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
stdout_bin.write(chunk)
|
|
164
|
+
finally:
|
|
165
|
+
stream.close()
|
|
166
|
+
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cmd_version(_args: argparse.Namespace) -> int:
|
|
171
|
+
"""Print version."""
|
|
172
|
+
from . import __version__
|
|
173
|
+
print(f"c4py {__version__}")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str] | None = None) -> int:
|
|
178
|
+
"""Parse arguments and dispatch to the appropriate subcommand."""
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
prog="c4py",
|
|
181
|
+
description="C4 universal content identification — SMPTE ST 2114:2017",
|
|
182
|
+
)
|
|
183
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
184
|
+
|
|
185
|
+
# id
|
|
186
|
+
id_parser = subparsers.add_parser("id", help="identify file or directory")
|
|
187
|
+
id_parser.add_argument("path", help="file or directory to identify")
|
|
188
|
+
id_parser.add_argument(
|
|
189
|
+
"-i", "--id-only", action="store_true",
|
|
190
|
+
help="output bare C4 ID only (no c4m format)",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# diff
|
|
194
|
+
diff_parser = subparsers.add_parser("diff", help="compare two c4m manifests")
|
|
195
|
+
diff_parser.add_argument("old", help="old manifest path")
|
|
196
|
+
diff_parser.add_argument("new", help="new manifest path")
|
|
197
|
+
|
|
198
|
+
# verify
|
|
199
|
+
verify_parser = subparsers.add_parser("verify", help="verify directory matches manifest")
|
|
200
|
+
verify_parser.add_argument("manifest", help="c4m manifest path")
|
|
201
|
+
verify_parser.add_argument("directory", help="directory to verify")
|
|
202
|
+
|
|
203
|
+
# cat
|
|
204
|
+
cat_parser = subparsers.add_parser("cat", help="retrieve content from store")
|
|
205
|
+
cat_parser.add_argument("c4id", help="C4 ID to retrieve")
|
|
206
|
+
|
|
207
|
+
# version
|
|
208
|
+
subparsers.add_parser("version", help="print version")
|
|
209
|
+
|
|
210
|
+
args = parser.parse_args(argv)
|
|
211
|
+
|
|
212
|
+
if args.command is None:
|
|
213
|
+
parser.print_help()
|
|
214
|
+
return 1
|
|
215
|
+
|
|
216
|
+
dispatch = {
|
|
217
|
+
"id": _cmd_id,
|
|
218
|
+
"diff": _cmd_diff,
|
|
219
|
+
"verify": _cmd_verify,
|
|
220
|
+
"cat": _cmd_cat,
|
|
221
|
+
"version": _cmd_version,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return dispatch[args.command](args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|