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.
- ocsf_toolkit/__init__.py +55 -0
- ocsf_toolkit/_version.py +24 -0
- ocsf_toolkit/cli/__init__.py +113 -0
- ocsf_toolkit/cli/_common.py +19 -0
- ocsf_toolkit/cli/_upstream.py +147 -0
- ocsf_toolkit/cli/check.py +77 -0
- ocsf_toolkit/cli/compile.py +39 -0
- ocsf_toolkit/cli/diff.py +65 -0
- ocsf_toolkit/cli/generate.py +90 -0
- ocsf_toolkit/cli/ir.py +113 -0
- ocsf_toolkit/cli/lock.py +331 -0
- ocsf_toolkit/cli/models.py +88 -0
- ocsf_toolkit/cli/trees.py +27 -0
- ocsf_toolkit/codegen/__init__.py +10 -0
- ocsf_toolkit/codegen/emit.py +239 -0
- ocsf_toolkit/codegen/generate.py +337 -0
- ocsf_toolkit/codegen/layout.py +74 -0
- ocsf_toolkit/codegen/naming.py +65 -0
- ocsf_toolkit/codegen/types.py +27 -0
- ocsf_toolkit/conform.py +396 -0
- ocsf_toolkit/data/ir-1.0.0.json +28073 -0
- ocsf_toolkit/data/ir-1.1.0.json +41579 -0
- ocsf_toolkit/data/ir-1.2.0.json +50321 -0
- ocsf_toolkit/data/ir-1.3.0.json +57198 -0
- ocsf_toolkit/data/ir-1.4.0.json +85424 -0
- ocsf_toolkit/data/ir-1.5.0.json +91969 -0
- ocsf_toolkit/data/ir-1.6.0.json +94639 -0
- ocsf_toolkit/data/ir-1.7.0.json +96525 -0
- ocsf_toolkit/data/ir-1.8.0.json +98267 -0
- ocsf_toolkit/data/ir-1.9.0.json +108067 -0
- ocsf_toolkit/derive.py +288 -0
- ocsf_toolkit/diff.py +123 -0
- ocsf_toolkit/engine.py +243 -0
- ocsf_toolkit/export.py +460 -0
- ocsf_toolkit/ir.py +335 -0
- ocsf_toolkit/loader.py +290 -0
- ocsf_toolkit/models.py +177 -0
- ocsf_toolkit/observables.py +196 -0
- ocsf_toolkit/py.typed +0 -0
- ocsf_toolkit/schema.py +284 -0
- ocsf_toolkit/walk.py +189 -0
- ocsf_toolkit-0.1.0a1.dist-info/METADATA +205 -0
- ocsf_toolkit-0.1.0a1.dist-info/RECORD +46 -0
- ocsf_toolkit-0.1.0a1.dist-info/WHEEL +4 -0
- ocsf_toolkit-0.1.0a1.dist-info/entry_points.txt +2 -0
- ocsf_toolkit-0.1.0a1.dist-info/licenses/LICENSE +202 -0
ocsf_toolkit/__init__.py
ADDED
|
@@ -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
|
+
]
|
ocsf_toolkit/_version.py
ADDED
|
@@ -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
|
ocsf_toolkit/cli/diff.py
ADDED
|
@@ -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)
|