ocsf-toolkit 0.1.0a1__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.
Files changed (46) hide show
  1. ocsf_toolkit/__init__.py +55 -0
  2. ocsf_toolkit/_version.py +24 -0
  3. ocsf_toolkit/cli/__init__.py +113 -0
  4. ocsf_toolkit/cli/_common.py +19 -0
  5. ocsf_toolkit/cli/_upstream.py +147 -0
  6. ocsf_toolkit/cli/check.py +77 -0
  7. ocsf_toolkit/cli/compile.py +39 -0
  8. ocsf_toolkit/cli/diff.py +65 -0
  9. ocsf_toolkit/cli/generate.py +90 -0
  10. ocsf_toolkit/cli/ir.py +113 -0
  11. ocsf_toolkit/cli/lock.py +331 -0
  12. ocsf_toolkit/cli/models.py +88 -0
  13. ocsf_toolkit/cli/trees.py +27 -0
  14. ocsf_toolkit/codegen/__init__.py +10 -0
  15. ocsf_toolkit/codegen/emit.py +239 -0
  16. ocsf_toolkit/codegen/generate.py +337 -0
  17. ocsf_toolkit/codegen/layout.py +74 -0
  18. ocsf_toolkit/codegen/naming.py +65 -0
  19. ocsf_toolkit/codegen/types.py +27 -0
  20. ocsf_toolkit/conform.py +396 -0
  21. ocsf_toolkit/data/ir-1.0.0.json +28073 -0
  22. ocsf_toolkit/data/ir-1.1.0.json +41579 -0
  23. ocsf_toolkit/data/ir-1.2.0.json +50321 -0
  24. ocsf_toolkit/data/ir-1.3.0.json +57198 -0
  25. ocsf_toolkit/data/ir-1.4.0.json +85424 -0
  26. ocsf_toolkit/data/ir-1.5.0.json +91969 -0
  27. ocsf_toolkit/data/ir-1.6.0.json +94639 -0
  28. ocsf_toolkit/data/ir-1.7.0.json +96525 -0
  29. ocsf_toolkit/data/ir-1.8.0.json +98267 -0
  30. ocsf_toolkit/data/ir-1.9.0.json +108067 -0
  31. ocsf_toolkit/derive.py +288 -0
  32. ocsf_toolkit/diff.py +123 -0
  33. ocsf_toolkit/engine.py +243 -0
  34. ocsf_toolkit/export.py +460 -0
  35. ocsf_toolkit/ir.py +335 -0
  36. ocsf_toolkit/loader.py +290 -0
  37. ocsf_toolkit/models.py +177 -0
  38. ocsf_toolkit/observables.py +196 -0
  39. ocsf_toolkit/py.typed +0 -0
  40. ocsf_toolkit/schema.py +284 -0
  41. ocsf_toolkit/walk.py +189 -0
  42. ocsf_toolkit-0.1.0a1.dist-info/METADATA +205 -0
  43. ocsf_toolkit-0.1.0a1.dist-info/RECORD +46 -0
  44. ocsf_toolkit-0.1.0a1.dist-info/WHEEL +4 -0
  45. ocsf_toolkit-0.1.0a1.dist-info/entry_points.txt +2 -0
  46. ocsf_toolkit-0.1.0a1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,55 @@
1
+ """Version-pinned OCSF semantics: the schema as data, event building and checking, observables, diff."""
2
+
3
+ from ocsf_toolkit._version import __version__
4
+ from ocsf_toolkit.codegen import generate
5
+ from ocsf_toolkit.diff import SchemaDiff, diff
6
+ from ocsf_toolkit.engine import (
7
+ LENIENT,
8
+ REPAIR,
9
+ STRICT,
10
+ ConformanceError,
11
+ Finding,
12
+ Kind,
13
+ Policy,
14
+ Report,
15
+ extract,
16
+ )
17
+ from ocsf_toolkit.ir import SchemaIR
18
+ from ocsf_toolkit.models import build, parse, repair
19
+ from ocsf_toolkit.observables import Observable, merge
20
+ from ocsf_toolkit.schema import (
21
+ ResolvedAttribute,
22
+ ResolvedItem,
23
+ ResolvedSchema,
24
+ load,
25
+ load_ir,
26
+ resolve,
27
+ )
28
+
29
+ __all__ = [
30
+ "LENIENT",
31
+ "REPAIR",
32
+ "STRICT",
33
+ "ConformanceError",
34
+ "Finding",
35
+ "Kind",
36
+ "Observable",
37
+ "Policy",
38
+ "Report",
39
+ "ResolvedAttribute",
40
+ "ResolvedItem",
41
+ "ResolvedSchema",
42
+ "SchemaDiff",
43
+ "SchemaIR",
44
+ "__version__",
45
+ "build",
46
+ "diff",
47
+ "extract",
48
+ "generate",
49
+ "load",
50
+ "load_ir",
51
+ "merge",
52
+ "parse",
53
+ "repair",
54
+ "resolve",
55
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0a1'
22
+ __version_tuple__ = version_tuple = (0, 1, 0, 'a1')
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,113 @@
1
+ """The ``ocsf-toolkit`` console script: an argparse root dispatching to one subcommand module."""
2
+
3
+ import argparse
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from ocsf_toolkit import __version__
10
+ from ocsf_toolkit.cli import check as check_command
11
+ from ocsf_toolkit.cli import compile as compile_command
12
+ from ocsf_toolkit.cli import diff as diff_command
13
+ from ocsf_toolkit.cli import generate as generate_command
14
+ from ocsf_toolkit.cli import ir as ir_command
15
+ from ocsf_toolkit.cli import lock as lock_command
16
+ from ocsf_toolkit.cli import models as models_command
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ """Build the argument parser: the root options and one subparser per command."""
21
+ # Shared options, each given only to the commands that read it.
22
+ lockfile = argparse.ArgumentParser(add_help=False)
23
+ lockfile.add_argument("--lock", default="ocsf.lock", help="lockfile path (default: %(default)s)")
24
+ build = argparse.ArgumentParser(add_help=False)
25
+ build.add_argument("--build-dir", default="build", help="checkout cache and output root (default: %(default)s)")
26
+ compiler = argparse.ArgumentParser(add_help=False)
27
+ compiler.add_argument(
28
+ "--compiler-cmd",
29
+ help="command that compiles a schema checkout to stdout, replacing uvx "
30
+ "(air-gapped builds); the checkout path is appended",
31
+ )
32
+ compiling = [lockfile, build, compiler]
33
+
34
+ parser = argparse.ArgumentParser(prog="ocsf-toolkit", description="Version-pinned OCSF schema tooling.")
35
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
36
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
37
+ compile_command.register(
38
+ sub.add_parser(
39
+ "compile",
40
+ parents=compiling,
41
+ help="compile lockfile schemas and check their hashes",
42
+ description="Clone each pinned commit, compile it with ocsf-schema-compiler under "
43
+ "Python 3.14, and assert compiled_sha256 and the compiled version.",
44
+ )
45
+ )
46
+ lock_command.register(
47
+ sub.add_parser(
48
+ "lock",
49
+ parents=compiling,
50
+ help="add an OCSF version to ocsf.lock",
51
+ description="Resolve an upstream ref, pin its commit, compile it, and record compiled_sha256.",
52
+ )
53
+ )
54
+ ir_command.register(
55
+ sub.add_parser(
56
+ "ir",
57
+ help="export or check canonical IR JSON",
58
+ description="Build the IR from build/compiled and write or check data/ir-<ver>.json.",
59
+ ),
60
+ [lockfile, build],
61
+ )
62
+ check_command.register(
63
+ sub.add_parser(
64
+ "check",
65
+ help="report how events deviate from an OCSF version",
66
+ description="Check each event in JSON Lines files against an OCSF version and print "
67
+ "its findings; exit 1 if any event has an error.",
68
+ )
69
+ )
70
+ diff_command.register(
71
+ sub.add_parser(
72
+ "diff",
73
+ help="show what changed between two OCSF versions",
74
+ description="Compare two IRs field by field, matching classes by uid so a renamed "
75
+ "class shows as a rename; exit 0 if equal, 1 if they differ, 2 on error.",
76
+ )
77
+ )
78
+ generate_command.register(
79
+ sub.add_parser(
80
+ "generate",
81
+ parents=compiling,
82
+ help="generate a typed msgspec model package",
83
+ description="Project an OCSF version or IR into a typed package of msgspec Structs: "
84
+ "superset classes by default, exact classes for a fixed --profiles set.",
85
+ )
86
+ )
87
+ models_command.register(
88
+ sub.add_parser(
89
+ "models",
90
+ help="check or record the generated model trees (regeneration gate)",
91
+ description="Render each model-window version twice from its shipped IR. check asserts the "
92
+ "tree is stable and matches models_tree_sha256 in the lockfile; record writes that hash.",
93
+ ),
94
+ [lockfile],
95
+ )
96
+ return parser
97
+
98
+
99
+ def main(argv: Sequence[str] | None = None) -> int:
100
+ """Run one command and return its exit status; an expected failure prints one line to stderr."""
101
+ args = build_parser().parse_args(argv)
102
+ try:
103
+ status: int = args.run(args)
104
+ sys.stdout.flush() # a closed pipe shows up here for output that fit in the buffer
105
+ except BrokenPipeError:
106
+ # The reader went away (`| head`): not an error of ours. Point stdout at devnull so the
107
+ # interpreter's own flush at exit doesn't raise again, and stop quietly.
108
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
109
+ return 1
110
+ except (ValueError, OSError, subprocess.CalledProcessError) as exc:
111
+ print(f"ocsf-toolkit: error: {exc}", file=sys.stderr)
112
+ return 1
113
+ return status
@@ -0,0 +1,19 @@
1
+ """Helpers more than one subcommand uses."""
2
+
3
+ from pathlib import Path
4
+
5
+ from ocsf_toolkit.ir import SchemaIR
6
+ from ocsf_toolkit.schema import load, load_ir
7
+
8
+
9
+ def printable(text: str) -> str:
10
+ """Escape control characters: paths and messages carry keys taken from the input."""
11
+ return "".join(c if c.isprintable() else repr(c)[1:-1] for c in text)
12
+
13
+
14
+ def source_ir(source: str) -> SchemaIR:
15
+ """Load a shipped OCSF version by number, or an IR JSON file by path."""
16
+ path = Path(source)
17
+ if source.endswith(".json") or path.is_file():
18
+ return load_ir(path)
19
+ return load(source).ir
@@ -0,0 +1,147 @@
1
+ """Upstream ocsf-schema: check out a pinned commit and compile it into a verified schema (on 3.14)."""
2
+
3
+ import hashlib
4
+ import json
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import cast, final
14
+
15
+
16
+ @final
17
+ @dataclass(frozen=True, slots=True)
18
+ class Compiler:
19
+ """How to run ocsf-schema-compiler: the pinned version under its Python, or a replacement command."""
20
+
21
+ version: str # ocsf-schema-compiler release, from the lockfile's [toolchain]
22
+ python: str # the interpreter uvx runs it under
23
+ cmd: str | None = None # replaces the uvx invocation (air-gapped builds); the checkout is appended
24
+
25
+
26
+ def compiler_argv(src: Path, compiler: Compiler, extensions: Sequence[Path] = ()) -> list[str]:
27
+ """Return the command line that compiles the schema checkout at ``src`` to JSON on stdout.
28
+
29
+ Each of ``extensions`` is a directory of custom extensions, passed as ``-e PATH``.
30
+ """
31
+ ext_args = [arg for path in extensions for arg in ("-e", str(path))]
32
+ if compiler.cmd is not None:
33
+ return [*shlex.split(compiler.cmd), str(src), *ext_args]
34
+ return [
35
+ "uvx",
36
+ "--quiet",
37
+ "--python",
38
+ compiler.python,
39
+ "--from",
40
+ f"ocsf-schema-compiler=={compiler.version}",
41
+ "python",
42
+ "-m",
43
+ "ocsf_schema_compiler",
44
+ str(src),
45
+ *ext_args,
46
+ ]
47
+
48
+
49
+ def checkout(repo: str, commit: str, cache_dir: Path) -> Path:
50
+ """Return a checkout of ``repo`` at ``commit``, reusing ``cache_dir/<commit>`` when present."""
51
+ dest = cache_dir / commit
52
+ if dest.is_dir():
53
+ head = _git("rev-parse", "HEAD", cwd=dest)
54
+ if head != commit:
55
+ raise ValueError(f"{dest} is a checkout of {head}, not {commit}; delete it and retry")
56
+ return dest
57
+ cache_dir.mkdir(parents=True, exist_ok=True)
58
+ tmp = Path(tempfile.mkdtemp(dir=cache_dir, prefix=f".{commit[:12]}-"))
59
+ try:
60
+ _git("init", "--quiet", cwd=tmp)
61
+ _git("fetch", "--quiet", "--depth", "1", "--", repo, commit, cwd=tmp)
62
+ _git("-c", "advice.detachedHead=false", "checkout", "--quiet", "FETCH_HEAD", cwd=tmp)
63
+ tmp.rename(dest)
64
+ finally:
65
+ if tmp.exists():
66
+ shutil.rmtree(tmp)
67
+ return dest
68
+
69
+
70
+ def verify(output: bytes, *, version: str, sha256: str | None) -> tuple[bytes, str]:
71
+ """Check compiler output against the lockfile; return its canonical bytes and their sha256.
72
+
73
+ The compiler emits keys in filesystem directory order (unsorted ``os.walk``), so its raw bytes
74
+ differ between machines; the hash is over canonical JSON (sorted keys, compact separators,
75
+ UTF-8), the same rule as the IR export. The compiled ``version`` must equal the declared one:
76
+ an upstream tag can point at the wrong commit (tag ``1.5.0`` carries ``1.6.0-dev``).
77
+ ``sha256=None`` skips the hash check, for an entry whose hash is being recorded.
78
+ """
79
+ try:
80
+ compiled = json.loads(output)
81
+ except ValueError as exc:
82
+ raise ValueError(f"compiler output is not JSON: {exc}") from exc
83
+ actual: object = None
84
+ if isinstance(compiled, dict):
85
+ actual = cast(dict[str, object], compiled).get("version")
86
+ if actual != version:
87
+ raise ValueError(
88
+ f"compiled schema reports version {actual!r} but the lockfile declares {version!r}; "
89
+ "the pinned commit is not that release (upstream tag 1.5.0 is a known case)"
90
+ )
91
+ canonical = json.dumps(compiled, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
92
+ digest = hashlib.sha256(canonical).hexdigest()
93
+ if sha256 is not None and digest != sha256:
94
+ raise ValueError(
95
+ f"compiled schema {version} has sha256 {digest}, lockfile has {sha256}; compiler output changed"
96
+ )
97
+ return canonical, digest
98
+
99
+
100
+ def compile_commit(
101
+ *, repo: str, commit: str, version: str, sha256: str | None, compiler: Compiler, build_dir: Path
102
+ ) -> tuple[Path, str]:
103
+ """Check out, compile and verify one pinned schema; write it to ``build/compiled/``.
104
+
105
+ Returns the output path and its sha256. Nothing is written unless verification passes.
106
+ """
107
+ data, digest = compiled_output(
108
+ repo=repo, commit=commit, version=version, sha256=sha256, compiler=compiler, build_dir=build_dir
109
+ )
110
+ out = build_dir / "compiled" / f"schema-{version}.json"
111
+ atomic_write(out, data)
112
+ return out, digest
113
+
114
+
115
+ def compiled_output(
116
+ *,
117
+ repo: str,
118
+ commit: str,
119
+ version: str,
120
+ sha256: str | None,
121
+ compiler: Compiler,
122
+ build_dir: Path,
123
+ extensions: Sequence[Path] = (),
124
+ ) -> tuple[bytes, str]:
125
+ """Check out and compile one pinned schema, with any custom extensions.
126
+
127
+ Return the verified canonical bytes and their sha256 without writing them anywhere.
128
+ """
129
+ print(f"{version}: checkout {repo} @ {commit}", file=sys.stderr)
130
+ src = checkout(repo, commit, build_dir / "ocsf-schema")
131
+ argv = compiler_argv(src, compiler, extensions)
132
+ print(f"{version}: {shlex.join(argv)}", file=sys.stderr)
133
+ output = subprocess.run(argv, check=True, stdout=subprocess.PIPE).stdout
134
+ return verify(output, version=version, sha256=sha256)
135
+
136
+
137
+ def atomic_write(path: Path, data: bytes) -> None:
138
+ """Write data to path through a sibling ``.partial`` file, so a reader never sees half of it."""
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ partial = path.with_name(path.name + ".partial")
141
+ partial.write_bytes(data)
142
+ partial.replace(path)
143
+
144
+
145
+ def _git(*args: str, cwd: Path) -> str:
146
+ result = subprocess.run(["git", *args], cwd=cwd, check=True, stdout=subprocess.PIPE, text=True)
147
+ return result.stdout.strip()
@@ -0,0 +1,77 @@
1
+ """The ``check`` subcommand: a conformance report for each event in JSON Lines files."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import json
6
+ import sys
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+ from typing import Any, TextIO, cast
10
+
11
+ from ocsf_toolkit.cli._common import printable
12
+ from ocsf_toolkit.models import parse
13
+ from ocsf_toolkit.schema import load
14
+ from ocsf_toolkit.walk import dotted
15
+
16
+
17
+ def register(parser: argparse.ArgumentParser) -> None:
18
+ """Add the ``check`` arguments to its subparser."""
19
+ parser.add_argument("--version", required=True, help="OCSF version to check against")
20
+ parser.add_argument(
21
+ "--profiles",
22
+ help="comma-separated active profiles (default: each event's metadata.profiles)",
23
+ )
24
+ parser.add_argument(
25
+ "--class",
26
+ dest="class_key",
27
+ metavar="KEY",
28
+ help="class key (default: inferred from class_uid, then type_uid)",
29
+ )
30
+ parser.add_argument("--json", action="store_true", help="print one JSON report per event instead of text")
31
+ parser.add_argument("files", nargs="+", metavar="FILE", help="JSON Lines file of events; - reads stdin")
32
+ parser.set_defaults(run=run)
33
+
34
+
35
+ def run(args: argparse.Namespace) -> int:
36
+ """Check each event and print its findings; return 1 if any event has an error."""
37
+ profiles = None if args.profiles is None else [p for p in args.profiles.split(",") if p]
38
+ try: # fail once on a bad selection rather than once per event
39
+ schema = load(args.version, profiles=profiles or ())
40
+ except KeyError as exc:
41
+ raise ValueError(exc.args[0]) from None
42
+ if args.class_key is not None and args.class_key not in schema.classes:
43
+ raise ValueError(f"no class {args.class_key!r} in OCSF {args.version}")
44
+ failed = sum(not _check_event(f"{name}:{line}", text, args, profiles) for name, line, text in _lines(args.files))
45
+ return 1 if failed else 0
46
+
47
+
48
+ def _check_event(where: str, text: str, args: argparse.Namespace, profiles: list[str] | None) -> bool:
49
+ """Print one event's findings; return whether it has no errors."""
50
+ try:
51
+ event: object = json.loads(text)
52
+ except ValueError as exc:
53
+ print(f"{where}: error: not JSON: {exc}")
54
+ return False
55
+ if not isinstance(event, dict):
56
+ print(f"{where}: error: not a JSON object")
57
+ return False
58
+ _, report = parse(cast(dict[str, Any], event), version=args.version, profiles=profiles, class_key=args.class_key)
59
+ if args.json:
60
+ print(json.dumps({"source": where, **report.to_dict()})) # ASCII: no raw controls
61
+ return report.ok()
62
+ for f in report.findings:
63
+ if f.severity != "info":
64
+ path = dotted(f.path) or "."
65
+ print(printable(f"{where}: {f.severity} {f.kind.value} {path}: {f.message}"))
66
+ return report.ok()
67
+
68
+
69
+ def _lines(files: list[str]) -> Iterator[tuple[str, int, str]]:
70
+ for name in files:
71
+ stream: contextlib.AbstractContextManager[TextIO] = (
72
+ contextlib.nullcontext(sys.stdin) if name == "-" else Path(name).open(encoding="utf-8") # noqa: SIM115 -- entered on the next line
73
+ )
74
+ with stream as lines:
75
+ for n, text in enumerate(lines, 1):
76
+ if text.strip():
77
+ yield name, n, text
@@ -0,0 +1,39 @@
1
+ """The ``compile`` subcommand: rebuild ``build/compiled/schema-<ver>.json`` for lockfile entries."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from ocsf_toolkit.cli import _upstream, lock
7
+
8
+
9
+ def register(parser: argparse.ArgumentParser) -> None:
10
+ """Add the ``compile`` arguments to its subparser."""
11
+ parser.add_argument(
12
+ "versions",
13
+ nargs="*",
14
+ metavar="VERSION",
15
+ help="OCSF versions to compile (default: every lockfile entry)",
16
+ )
17
+ parser.set_defaults(run=run)
18
+
19
+
20
+ def run(args: argparse.Namespace) -> int:
21
+ """Compile each requested lockfile schema and check its hash and version."""
22
+ lockfile = lock.read(Path(args.lock))
23
+ versions: list[str] = args.versions or [entry.version for entry in lockfile.schemas]
24
+ for version in versions:
25
+ entry = lock.find(lockfile, version)
26
+ out, digest = _upstream.compile_commit(
27
+ repo=entry.repo,
28
+ commit=entry.commit,
29
+ version=entry.version,
30
+ sha256=entry.compiled_sha256,
31
+ compiler=lock.compiler(lockfile, args.compiler_cmd),
32
+ build_dir=Path(args.build_dir),
33
+ )
34
+ print(
35
+ f"{entry.version}: ok ref {entry.ref} commit {entry.commit}\n"
36
+ f"{entry.version}: compiled version matches the declared one\n"
37
+ f"{entry.version}: sha256 {digest} == lockfile -> {out}"
38
+ )
39
+ return 0
@@ -0,0 +1,65 @@
1
+ """The ``diff`` subcommand: what changed between two OCSF versions or IR files."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from ocsf_toolkit.cli._common import printable, source_ir
8
+ from ocsf_toolkit.diff import Change, SchemaDiff, diff, plain
9
+
10
+
11
+ def register(parser: argparse.ArgumentParser) -> None:
12
+ """Add the ``diff`` arguments to its subparser."""
13
+ for name in ("old", "new"):
14
+ parser.add_argument(name, metavar=name.upper(), help="a shipped OCSF version or an IR file")
15
+ output = parser.add_mutually_exclusive_group()
16
+ output.add_argument("--json", action="store_true", help="print the diff as one JSON object")
17
+ output.add_argument(
18
+ "--semver",
19
+ action="store_true",
20
+ help="print only the release category for re-pinning OLD as NEW: patch, minor or major",
21
+ )
22
+ parser.set_defaults(run=run)
23
+
24
+
25
+ def run(args: argparse.Namespace) -> int:
26
+ """Print the diff of two IRs; return 0 when they are equal, 1 when they differ, 2 when one can't be read."""
27
+ try:
28
+ old, new = source_ir(args.old), source_ir(args.new)
29
+ except (ValueError, OSError) as exc:
30
+ print(f"ocsf-toolkit: error: {exc}", file=sys.stderr)
31
+ return 2
32
+ result = diff(old, new)
33
+ if args.semver:
34
+ category, counted = semver(result)
35
+ print(f"{category} ({counted} change(s) outside provenance)")
36
+ elif args.json:
37
+ print(json.dumps(result.to_dict()))
38
+ else:
39
+ for change in result.changes:
40
+ print(printable(_line(change)))
41
+ return 1 if result.changes else 0
42
+
43
+
44
+ def semver(result: SchemaDiff) -> tuple[str, int]:
45
+ """Return the library release a changed IR of an already-shipped OCSF version calls for.
46
+
47
+ Repo plan §4.1 gray case: nothing -> patch, only additions -> minor, anything else -> major.
48
+
49
+ Provenance is left out: a compiler bump rewrites it without changing what the IR says.
50
+ """
51
+ changes = [c for c in result.changes if c.path[:1] != ("provenance",)]
52
+ if not changes:
53
+ return "patch", 0
54
+ if all(c.kind == "added" for c in changes):
55
+ return "minor", len(changes)
56
+ return "major", len(changes)
57
+
58
+
59
+ def _line(c: Change) -> str:
60
+ where = ".".join(str(p) for p in c.path)
61
+ if c.kind == "renamed":
62
+ return f"renamed {where} -> {c.after}"
63
+ if c.kind != "changed":
64
+ return f"{c.kind} {where}"
65
+ return f"changed {where}: {json.dumps(plain(c.before))} -> {json.dumps(plain(c.after))}"
@@ -0,0 +1,90 @@
1
+ """The ``generate`` subcommand: a typed model package from an OCSF version or IR (D#5)."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from ocsf_toolkit.cli import _upstream, lock
7
+ from ocsf_toolkit.cli._common import source_ir
8
+ from ocsf_toolkit.cli.trees import files_sha256, read_tree
9
+ from ocsf_toolkit.codegen import generate
10
+ from ocsf_toolkit.ir import Provenance, SchemaIR
11
+ from ocsf_toolkit.loader import load_compiled
12
+
13
+
14
+ def register(parser: argparse.ArgumentParser) -> None:
15
+ """Add the ``generate`` arguments to its subparser."""
16
+ parser.add_argument(
17
+ "source",
18
+ metavar="SOURCE",
19
+ help="a shipped OCSF version or an IR file; with --ext, a lockfile version to compile",
20
+ )
21
+ parser.add_argument("--package", required=True, help="import name of the generated package")
22
+ parser.add_argument("--out", required=True, help="directory to write <package>/ into")
23
+ parser.add_argument(
24
+ "--profiles",
25
+ help="comma-separated profiles: emit exact classes for this set (default: superset)",
26
+ )
27
+ parser.add_argument(
28
+ "--extensions",
29
+ help="comma-separated extensions to include (default: all; empty for none)",
30
+ )
31
+ parser.add_argument(
32
+ "--ext",
33
+ action="append",
34
+ default=[],
35
+ metavar="PATH",
36
+ help="a directory of custom extensions to compile SOURCE with (repeatable; compiled through uvx "
37
+ "under the lockfile's python-compile)",
38
+ )
39
+ parser.set_defaults(run=run)
40
+
41
+
42
+ def run(args: argparse.Namespace) -> int:
43
+ """Generate the model package and print where it was written."""
44
+ ir = _extended(args) if args.ext else source_ir(args.source)
45
+ try:
46
+ path = generate(
47
+ ir,
48
+ package=args.package,
49
+ out=Path(args.out),
50
+ profiles=_names(args.profiles),
51
+ extensions=_names(args.extensions),
52
+ )
53
+ except KeyError as exc: # an unknown profile or extension name
54
+ raise ValueError(exc.args[0]) from exc
55
+ modules = sum(1 for _ in path.rglob("*.py"))
56
+ mode = f"exact for profiles {args.profiles}" if args.profiles is not None else "superset"
57
+ print(f"{path}: OCSF {ir.version}, {mode}, {modules} modules")
58
+ return 0
59
+
60
+
61
+ def _names(value: str | None) -> list[str] | None:
62
+ return None if value is None else [n.strip() for n in value.split(",") if n.strip()]
63
+
64
+
65
+ def _extended(args: argparse.Namespace) -> SchemaIR:
66
+ """Compile a lockfile version with custom extension directories into an IR."""
67
+ lockfile = lock.read(Path(args.lock))
68
+ entry = lock.find(lockfile, args.source)
69
+ paths = [Path(p) for p in args.ext]
70
+ for path in paths:
71
+ if not path.is_dir():
72
+ raise ValueError(f"--ext {path}: not a directory")
73
+ data, digest = _upstream.compiled_output(
74
+ repo=entry.repo,
75
+ commit=entry.commit,
76
+ version=entry.version,
77
+ sha256=None, # extensions change the output; the declared version is still checked
78
+ compiler=lock.compiler(lockfile, args.compiler_cmd),
79
+ build_dir=Path(args.build_dir),
80
+ extensions=paths,
81
+ )
82
+ provenance = Provenance(
83
+ schema_repo=entry.repo,
84
+ ref=entry.ref,
85
+ commit=entry.commit,
86
+ compiler_version=lockfile.toolchain.ocsf_schema_compiler,
87
+ compiler_output_sha256=digest,
88
+ extensions=tuple((p.resolve().name, "local", files_sha256(read_tree(p))) for p in paths),
89
+ )
90
+ return load_compiled(data, provenance)