ruleforge-python 0.1.1__py3-none-win_amd64.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.
@@ -0,0 +1,50 @@
1
+ """Ruleforge Python SDK."""
2
+
3
+ from ._version import __version__
4
+ from .alerts import Alert, parse_alert_line
5
+ from .binary import BinaryResolutionError, resolve_policy_exe
6
+ from .cli import (
7
+ RunMetrics,
8
+ RunResult,
9
+ ServiceHandle,
10
+ bundle_build,
11
+ bundle_inspect,
12
+ fmt,
13
+ run,
14
+ serve,
15
+ test,
16
+ )
17
+ from .async_service import AsyncRuleforgeService
18
+ from .retry_queue import RetryQueueSink
19
+ from .service import RuleforgeService, ServeOptions
20
+ from .sinks import AsyncAlertSink, AlertSink, FileJsonlSink, StdoutSink, WebhookSink
21
+ from .sources import SourceDefaults, SourceSpec, SourcesConfig
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "Alert",
26
+ "AlertSink",
27
+ "AsyncAlertSink",
28
+ "AsyncRuleforgeService",
29
+ "BinaryResolutionError",
30
+ "FileJsonlSink",
31
+ "RetryQueueSink",
32
+ "RunMetrics",
33
+ "RunResult",
34
+ "RuleforgeService",
35
+ "ServeOptions",
36
+ "ServiceHandle",
37
+ "SourceDefaults",
38
+ "SourceSpec",
39
+ "SourcesConfig",
40
+ "StdoutSink",
41
+ "WebhookSink",
42
+ "bundle_build",
43
+ "bundle_inspect",
44
+ "fmt",
45
+ "parse_alert_line",
46
+ "resolve_policy_exe",
47
+ "run",
48
+ "serve",
49
+ "test",
50
+ ]
@@ -0,0 +1,9 @@
1
+ """Package version.
2
+
3
+ Release automation should set RULEFORGE_VERSION to match the core Ruleforge
4
+ release tag.
5
+ """
6
+
7
+ import os
8
+
9
+ __version__ = os.environ.get("RULEFORGE_VERSION", "0.0.0")
@@ -0,0 +1,86 @@
1
+ """Alert models and parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Mapping
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Alert:
12
+ ts: str
13
+ source_id: str
14
+ source: str
15
+ rule: str
16
+ severity: str
17
+ tags: tuple[str, ...] = field(default_factory=tuple)
18
+ emit: Mapping[str, Any] = field(default_factory=dict)
19
+ because: Mapping[str, Any] | None = None
20
+ raw: Mapping[str, Any] = field(default_factory=dict)
21
+
22
+ @classmethod
23
+ def from_dict(cls, payload: Mapping[str, Any]) -> "Alert":
24
+ tags: tuple[str, ...] = tuple(
25
+ str(tag) for tag in payload.get("tags", []) if isinstance(tag, (str, int, float))
26
+ )
27
+ emit = payload.get("emit")
28
+ if not isinstance(emit, Mapping):
29
+ emit = {}
30
+ because = payload.get("because")
31
+ if because is not None and not isinstance(because, Mapping):
32
+ because = None
33
+
34
+ return cls(
35
+ ts=str(payload.get("ts", "")),
36
+ source_id=str(payload.get("source_id", "")),
37
+ source=str(payload.get("source", "")),
38
+ rule=str(payload.get("rule", "")),
39
+ severity=str(payload.get("severity", "")),
40
+ tags=tags,
41
+ emit=emit,
42
+ because=because,
43
+ raw=dict(payload),
44
+ )
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ data = dict(self.raw)
48
+ if data:
49
+ return data
50
+ return {
51
+ "ts": self.ts,
52
+ "source_id": self.source_id,
53
+ "source": self.source,
54
+ "rule": self.rule,
55
+ "severity": self.severity,
56
+ "tags": list(self.tags),
57
+ "emit": dict(self.emit),
58
+ "because": None if self.because is None else dict(self.because),
59
+ }
60
+
61
+ def to_json(self) -> str:
62
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
63
+
64
+
65
+ def parse_alert_line(line: str) -> Alert | None:
66
+ """Parse a single JSONL line from policy output.
67
+
68
+ Returns Alert for match records, otherwise None.
69
+ """
70
+
71
+ text = line.strip()
72
+ if not text or not text.startswith("{"):
73
+ return None
74
+
75
+ try:
76
+ payload = json.loads(text)
77
+ except json.JSONDecodeError:
78
+ return None
79
+
80
+ if not isinstance(payload, dict):
81
+ return None
82
+
83
+ if "rule" not in payload or "severity" not in payload:
84
+ return None
85
+
86
+ return Alert.from_dict(payload)
@@ -0,0 +1,106 @@
1
+ """Async wrapper for RuleforgeService."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any, Mapping, Sequence
9
+
10
+ from .alerts import Alert
11
+ from .service import RuleforgeService, ServeOptions
12
+ from .sinks import AlertSink
13
+
14
+ _SENTINEL = object()
15
+
16
+
17
+ class AsyncRuleforgeService:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ sources_config: str | Path,
22
+ sources_status: str | Path | None = None,
23
+ policy_path: str | Path | None = None,
24
+ sinks: Sequence[AlertSink] | None = None,
25
+ options: ServeOptions | None = None,
26
+ extra_args: Sequence[str] | None = None,
27
+ serve_kwargs: Mapping[str, Any] | None = None,
28
+ cwd: str | Path | None = None,
29
+ env: Mapping[str, str] | None = None,
30
+ ) -> None:
31
+ self._service = RuleforgeService.from_sources_config(
32
+ sources_config=sources_config,
33
+ sources_status=sources_status,
34
+ policy_path=policy_path,
35
+ sinks=sinks,
36
+ options=options,
37
+ extra_args=extra_args,
38
+ serve_kwargs=serve_kwargs,
39
+ cwd=cwd,
40
+ env=env,
41
+ )
42
+ self._bridge_stop = threading.Event()
43
+ self._bridge_thread: threading.Thread | None = None
44
+ self._loop: asyncio.AbstractEventLoop | None = None
45
+ self._queue: asyncio.Queue[Alert | object] = asyncio.Queue()
46
+
47
+ async def start(self) -> None:
48
+ if self._bridge_thread and self._bridge_thread.is_alive():
49
+ return
50
+ self._drain_queue()
51
+ self._loop = asyncio.get_running_loop()
52
+ self._bridge_stop.clear()
53
+ self._service.start()
54
+ self._bridge_thread = threading.Thread(
55
+ target=self._bridge_alerts,
56
+ name="ruleforge-async-bridge",
57
+ daemon=True,
58
+ )
59
+ self._bridge_thread.start()
60
+
61
+ async def stop(self) -> None:
62
+ self._bridge_stop.set()
63
+ self._service.stop()
64
+ await asyncio.to_thread(self._service.wait)
65
+ if self._bridge_thread is not None:
66
+ self._bridge_thread.join(timeout=2.0)
67
+
68
+ async def wait(self, timeout: float | None = None) -> bool:
69
+ return await asyncio.to_thread(self._service.wait, timeout)
70
+
71
+ async def next_alert(self, timeout: float | None = None) -> Alert | None:
72
+ if timeout is None:
73
+ item = await self._queue.get()
74
+ else:
75
+ item = await asyncio.wait_for(self._queue.get(), timeout=timeout)
76
+ if item is _SENTINEL:
77
+ return None
78
+ return item
79
+
80
+ async def alerts(self):
81
+ while True:
82
+ item = await self._queue.get()
83
+ if item is _SENTINEL:
84
+ break
85
+ yield item
86
+
87
+ @property
88
+ def last_exit_code(self) -> int | None:
89
+ return self._service.last_exit_code
90
+
91
+ def _bridge_alerts(self) -> None:
92
+ for alert in self._service.iter_alerts():
93
+ if self._bridge_stop.is_set():
94
+ break
95
+ if self._loop is None:
96
+ break
97
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, alert)
98
+ if self._loop is not None:
99
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, _SENTINEL)
100
+
101
+ def _drain_queue(self) -> None:
102
+ while True:
103
+ try:
104
+ self._queue.get_nowait()
105
+ except asyncio.QueueEmpty:
106
+ return
@@ -0,0 +1,10 @@
1
+ The host-platform `policy` executable is staged here during wheel packaging.
2
+
3
+ Development helper:
4
+
5
+ ```bash
6
+ python python/scripts/build_release_artifacts.py \
7
+ --policy-binary-path <path-to-policy-binary> \
8
+ --output-root <build-output-root> \
9
+ --skip-tests
10
+ ```
@@ -0,0 +1,7 @@
1
+ {
2
+ "file": "policy.exe",
3
+ "sha256": "950586c2fc759b25ad431dc58b8eda968506421af126b37f04274c2e35623c6e",
4
+ "staged_utc": "2026-02-12T01:58:14.385884+00:00",
5
+ "platform": "windows",
6
+ "machine": "amd64"
7
+ }
@@ -0,0 +1,137 @@
1
+ """Policy executable resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ try:
15
+ from importlib import resources
16
+ except ImportError: # pragma: no cover
17
+ import importlib_resources as resources # type: ignore
18
+
19
+
20
+ class BinaryResolutionError(RuntimeError):
21
+ """Raised when no usable policy executable can be resolved."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BinaryResolution:
26
+ path: Path
27
+ source: str
28
+
29
+
30
+ _EMBEDDED_CACHE_PATH: Optional[Path] = None
31
+
32
+
33
+ def resolve_policy_exe(explicit_path: str | os.PathLike[str] | None = None) -> Path:
34
+ """Resolve policy executable path.
35
+
36
+ Resolution order:
37
+ 1) explicit path
38
+ 2) RULEFORGE_POLICY_PATH
39
+ 3) embedded package binary
40
+ 4) PATH lookup (platform-specific names)
41
+ """
42
+
43
+ resolved = resolve_policy(explicit_path)
44
+ return resolved.path
45
+
46
+
47
+ def resolve_policy(explicit_path: str | os.PathLike[str] | None = None) -> BinaryResolution:
48
+ if explicit_path:
49
+ explicit = Path(explicit_path).expanduser().resolve()
50
+ _validate_executable(explicit, "explicit path")
51
+ return BinaryResolution(path=explicit, source="explicit")
52
+
53
+ env_path = os.environ.get("RULEFORGE_POLICY_PATH")
54
+ if env_path:
55
+ env_candidate = Path(env_path).expanduser().resolve()
56
+ _validate_executable(env_candidate, "RULEFORGE_POLICY_PATH")
57
+ return BinaryResolution(path=env_candidate, source="env")
58
+
59
+ embedded = _resolve_embedded_policy()
60
+ if embedded is not None:
61
+ return BinaryResolution(path=embedded, source="embedded")
62
+
63
+ for name in _path_lookup_names():
64
+ found = shutil.which(name)
65
+ if found:
66
+ candidate = Path(found).expanduser().resolve()
67
+ _validate_executable(candidate, "PATH")
68
+ return BinaryResolution(path=candidate, source="path")
69
+
70
+ raise BinaryResolutionError(
71
+ "Unable to locate policy executable. Provide policy_path, set "
72
+ "RULEFORGE_POLICY_PATH, install a wheel that bundles a platform-native "
73
+ "policy binary, or add policy to PATH."
74
+ )
75
+
76
+
77
+ def _validate_executable(path: Path, source: str) -> None:
78
+ if not path.exists():
79
+ raise BinaryResolutionError(f"Policy executable from {source} does not exist: {path}")
80
+ if path.is_dir():
81
+ raise BinaryResolutionError(f"Policy executable from {source} is a directory: {path}")
82
+ if os.name != "nt" and not os.access(path, os.X_OK):
83
+ raise BinaryResolutionError(
84
+ f"Policy executable from {source} is not executable on this platform: {path}"
85
+ )
86
+
87
+
88
+ def _embedded_binary_name() -> str:
89
+ return "policy.exe" if os.name == "nt" else "policy"
90
+
91
+
92
+ def _alternate_embedded_binary_name() -> str:
93
+ return "policy" if os.name == "nt" else "policy.exe"
94
+
95
+
96
+ def _path_lookup_names() -> tuple[str, ...]:
97
+ if os.name == "nt":
98
+ return ("policy.exe", "policy")
99
+ return ("policy", "policy.exe")
100
+
101
+
102
+ def _resolve_embedded_policy() -> Optional[Path]:
103
+ global _EMBEDDED_CACHE_PATH
104
+
105
+ if _EMBEDDED_CACHE_PATH is not None and _EMBEDDED_CACHE_PATH.exists():
106
+ return _EMBEDDED_CACHE_PATH
107
+
108
+ try:
109
+ package_root = resources.files("ruleforge")
110
+ except ModuleNotFoundError:
111
+ return None
112
+
113
+ binary_name = _embedded_binary_name()
114
+ traversable = package_root.joinpath(f"bin/{binary_name}")
115
+ if not traversable.is_file():
116
+ alt_name = _alternate_embedded_binary_name()
117
+ alt_traversable = package_root.joinpath(f"bin/{alt_name}")
118
+ if alt_traversable.is_file():
119
+ raise BinaryResolutionError(
120
+ "Embedded policy binary does not match this platform "
121
+ f"(found {alt_name}, expected {binary_name}). Install a "
122
+ "wheel built for your OS/architecture."
123
+ )
124
+ return None
125
+
126
+ data = traversable.read_bytes()
127
+ digest = hashlib.sha256(data).hexdigest()[:16]
128
+ cache_dir = Path(tempfile.gettempdir()) / "ruleforge-python" / digest
129
+ cache_dir.mkdir(parents=True, exist_ok=True)
130
+ materialized = cache_dir / binary_name
131
+ if not materialized.exists():
132
+ materialized.write_bytes(data)
133
+ if os.name != "nt":
134
+ materialized.chmod(materialized.stat().st_mode | stat.S_IEXEC)
135
+
136
+ _EMBEDDED_CACHE_PATH = materialized
137
+ return materialized