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