Skip to content

Commit b50effd

Browse files
feat: add integrations, audit exporters, and CLI
1 parent e0b7f2e commit b50effd

23 files changed

Lines changed: 1199 additions & 1 deletion

gateframe/audit/exporters.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from abc import ABC, abstractmethod
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING, Any
7+
8+
import structlog
9+
10+
if TYPE_CHECKING:
11+
from gateframe.audit.log import AuditEntry
12+
13+
logger = structlog.get_logger()
14+
15+
16+
class AuditExporter(ABC):
17+
@abstractmethod
18+
def export(self, entry: AuditEntry) -> None: ...
19+
20+
@abstractmethod
21+
def flush(self) -> None: ...
22+
23+
def shutdown(self) -> None: # noqa: B027
24+
pass
25+
26+
27+
class JsonFileExporter(AuditExporter):
28+
def __init__(self, path: str | Path) -> None:
29+
self._path = Path(path)
30+
self._handle = open(self._path, "a") # noqa: SIM115
31+
32+
def export(self, entry: AuditEntry) -> None:
33+
line = json.dumps(entry.to_dict(), default=str)
34+
self._handle.write(line + "\n")
35+
36+
def flush(self) -> None:
37+
self._handle.flush()
38+
39+
def shutdown(self) -> None:
40+
self._handle.close()
41+
42+
43+
def _require_opentelemetry() -> None:
44+
try:
45+
import opentelemetry # noqa: F401
46+
except ImportError:
47+
raise ImportError(
48+
"opentelemetry is required for OtlpExporter. "
49+
"Install it with: pip install gateframe[otlp]"
50+
) from None
51+
52+
53+
class OtlpExporter(AuditExporter):
54+
def __init__(
55+
self,
56+
endpoint: str = "http://localhost:4317",
57+
*,
58+
insecure: bool = True,
59+
service_name: str = "gateframe",
60+
) -> None:
61+
_require_opentelemetry()
62+
63+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
64+
from opentelemetry.sdk._logs import LoggerProvider
65+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
66+
from opentelemetry.sdk.resources import Resource
67+
68+
resource = Resource.create({"service.name": service_name})
69+
self._provider = LoggerProvider(resource=resource)
70+
exporter = OTLPLogExporter(endpoint=endpoint, insecure=insecure)
71+
self._provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
72+
self._logger = self._provider.get_logger("gateframe.audit")
73+
74+
def export(self, entry: AuditEntry) -> None:
75+
from opentelemetry.sdk._logs import LogRecord
76+
77+
severity = self._resolve_severity(entry)
78+
attributes: dict[str, Any] = {
79+
"contract_name": entry.contract_name,
80+
"passed": entry.passed,
81+
"rules_applied": entry.rules_applied,
82+
"rules_failed": entry.rules_failed,
83+
}
84+
if entry.workflow_id is not None:
85+
attributes["workflow_id"] = entry.workflow_id
86+
if entry.confidence is not None:
87+
attributes["confidence"] = entry.confidence
88+
89+
record = LogRecord(
90+
body=json.dumps(entry.to_dict(), default=str),
91+
severity_number=severity,
92+
attributes=attributes,
93+
)
94+
self._logger.emit(record)
95+
96+
def flush(self) -> None:
97+
self._provider.force_flush()
98+
99+
def shutdown(self) -> None:
100+
self._provider.shutdown()
101+
102+
@staticmethod
103+
def _resolve_severity(entry: AuditEntry) -> int:
104+
import logging
105+
106+
has_hard = any(f.get("failure_mode") == "hard_fail" for f in entry.failures)
107+
has_soft = any(f.get("failure_mode") == "soft_fail" for f in entry.failures)
108+
if has_hard:
109+
return logging.ERROR
110+
if has_soft:
111+
return logging.WARNING
112+
return logging.INFO

gateframe/audit/log.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from gateframe.core.contract import ValidationResult
99

1010
if TYPE_CHECKING:
11+
from gateframe.audit.exporters import AuditExporter
1112
from gateframe.core.context import WorkflowContext
1213

1314
logger = structlog.get_logger()
@@ -62,8 +63,12 @@ def to_dict(self) -> dict[str, Any]:
6263

6364

6465
class AuditLog:
65-
def __init__(self) -> None:
66+
def __init__(
67+
self,
68+
exporters: list[AuditExporter] | None = None,
69+
) -> None:
6670
self._entries: list[AuditEntry] = []
71+
self._exporters: list[AuditExporter] = exporters or []
6772

6873
def record(
6974
self,
@@ -82,10 +87,20 @@ def record(
8287
log_kwargs["workflow_id"] = entry.workflow_id
8388
log_kwargs["confidence"] = entry.confidence
8489
logger.info("validation_event", **log_kwargs)
90+
for exporter in self._exporters:
91+
exporter.export(entry)
8592

8693
@property
8794
def entries(self) -> list[AuditEntry]:
8895
return list(self._entries)
8996

9097
def clear(self) -> None:
9198
self._entries.clear()
99+
100+
def flush(self) -> None:
101+
for exporter in self._exporters:
102+
exporter.flush()
103+
104+
def shutdown(self) -> None:
105+
for exporter in self._exporters:
106+
exporter.shutdown()

gateframe/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

gateframe/cli/inspect.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
import sys
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from gateframe.core.contract import ValidationContract
9+
10+
11+
def run_inspect(file_path: str) -> None:
12+
path = Path(file_path)
13+
if not path.exists():
14+
print(f"Error: file not found: {path}")
15+
sys.exit(1)
16+
if path.suffix != ".py":
17+
print(f"Error: not a Python file: {path}")
18+
sys.exit(1)
19+
20+
module = _import_file(path)
21+
contracts = _find_contracts(module)
22+
23+
if not contracts:
24+
print(f"No ValidationContract instances found in {path}")
25+
sys.exit(0)
26+
27+
print(f"\nFound {len(contracts)} contract(s) in {path.name}")
28+
for var_name, contract in contracts:
29+
_print_contract(var_name, contract)
30+
31+
32+
def _import_file(path: Path) -> Any: # noqa: ANN401 -- returns module
33+
parent = str(path.parent.resolve())
34+
if parent not in sys.path:
35+
sys.path.insert(0, parent)
36+
37+
spec = importlib.util.spec_from_file_location(path.stem, path)
38+
if spec is None or spec.loader is None:
39+
raise ImportError(f"Cannot load module from {path}")
40+
module = importlib.util.module_from_spec(spec)
41+
spec.loader.exec_module(module)
42+
return module
43+
44+
45+
def _find_contracts(module: Any) -> list[tuple[str, ValidationContract]]: # noqa: ANN401
46+
results = []
47+
for attr_name in dir(module):
48+
if attr_name.startswith("_"):
49+
continue
50+
obj = getattr(module, attr_name)
51+
if isinstance(obj, ValidationContract):
52+
results.append((attr_name, obj))
53+
return results
54+
55+
56+
def _print_contract(var_name: str, contract: ValidationContract) -> None:
57+
print(f"\n{'=' * 60}")
58+
print(f"Contract: {contract.name} (variable: {var_name})")
59+
print(f"{'=' * 60}")
60+
print(f" Rules ({len(contract.rules)}):")
61+
for i, rule in enumerate(contract.rules):
62+
rule_type = type(rule).__name__
63+
print(f" [{i}] {rule_type}: {rule.name}")
64+
if rule.description:
65+
print(f" {rule.description}")
66+
if hasattr(rule, "schema"):
67+
print(f" schema: {rule.schema.__name__}")
68+
if hasattr(rule, "failure_mode"):
69+
print(f" failure_mode: {rule.failure_mode.value}")
70+
if contract.on_hard_fail:
71+
print(f" on_hard_fail: {contract.on_hard_fail.value}")
72+
if contract.on_soft_fail:
73+
print(f" on_soft_fail: {contract.on_soft_fail.value}")

gateframe/cli/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import sys
5+
6+
7+
def cli() -> None:
8+
parser = argparse.ArgumentParser(prog="gateframe", description="gateframe CLI")
9+
subparsers = parser.add_subparsers(dest="command")
10+
11+
inspect_parser = subparsers.add_parser("inspect", help="Introspect a contract file")
12+
inspect_parser.add_argument("contract_file", help="Path to a .py file containing contracts")
13+
14+
replay_parser = subparsers.add_parser("replay", help="Replay an audit log")
15+
replay_parser.add_argument("audit_file", help="Path to a JSON/JSONL audit log file")
16+
17+
args = parser.parse_args()
18+
19+
if args.command == "inspect":
20+
from gateframe.cli.inspect import run_inspect
21+
22+
run_inspect(args.contract_file)
23+
elif args.command == "replay":
24+
from gateframe.cli.replay import run_replay
25+
26+
run_replay(args.audit_file)
27+
else:
28+
parser.print_help()
29+
sys.exit(1)
30+
31+
32+
if __name__ == "__main__":
33+
cli()

gateframe/cli/replay.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import sys
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import structlog
9+
10+
logger = structlog.get_logger()
11+
12+
13+
def run_replay(file_path: str) -> None:
14+
path = Path(file_path)
15+
if not path.exists():
16+
print(f"Error: file not found: {path}")
17+
sys.exit(1)
18+
19+
entries = _load_entries(path)
20+
if not entries:
21+
print("No entries found in audit log.")
22+
sys.exit(0)
23+
24+
_print_summary(entries)
25+
for i, entry in enumerate(entries):
26+
_print_entry(i, entry)
27+
28+
29+
def _load_entries(path: Path) -> list[dict[str, Any]]:
30+
text = path.read_text()
31+
32+
# Try JSON array first
33+
try:
34+
data = json.loads(text)
35+
if isinstance(data, list):
36+
return data
37+
return [data]
38+
except json.JSONDecodeError:
39+
pass
40+
41+
# Try JSON Lines
42+
entries: list[dict[str, Any]] = []
43+
for line_num, line in enumerate(text.splitlines(), 1):
44+
line = line.strip()
45+
if not line:
46+
continue
47+
try:
48+
entries.append(json.loads(line))
49+
except json.JSONDecodeError:
50+
logger.warning("skipping_invalid_line", line_number=line_num)
51+
return entries
52+
53+
54+
def _print_summary(entries: list[dict[str, Any]]) -> None:
55+
total = len(entries)
56+
passed = sum(1 for e in entries if e.get("passed"))
57+
failed = total - passed
58+
print(f"\nAudit Log: {total} entries ({passed} passed, {failed} failed)")
59+
print("=" * 60)
60+
61+
62+
def _print_entry(index: int, entry: dict[str, Any]) -> None:
63+
status = "PASS" if entry.get("passed") else "FAIL"
64+
contract = entry.get("contract_name", "?")
65+
print(f"\n [{index}] {contract} -- {status}")
66+
print(f" timestamp: {entry.get('timestamp', '?')}")
67+
print(
68+
f" rules: {entry.get('rules_applied', '?')} applied, "
69+
f"{entry.get('rules_failed', '?')} failed"
70+
)
71+
if entry.get("workflow_id"):
72+
print(f" workflow: {entry['workflow_id']}, confidence: {entry.get('confidence', '?')}")
73+
for failure in entry.get("failures", []):
74+
mode = failure.get("failure_mode", "?")
75+
name = failure.get("rule_name", "?")
76+
msg = failure.get("message", "?")
77+
print(f" FAILURE: [{mode}] {name}: {msg}")

gateframe/integrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)