specromancy-engine 0.1.3__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.
- specromancy_engine/__init__.py +32 -0
- specromancy_engine/__main__.py +8 -0
- specromancy_engine/_adapters/__init__.py +1 -0
- specromancy_engine/_adapters/adapter_contracts.py +81 -0
- specromancy_engine/_adapters/adapter_ownership.py +302 -0
- specromancy_engine/_adapters/adapter_rendering.py +218 -0
- specromancy_engine/_adapters/adapter_sources.py +140 -0
- specromancy_engine/_cli/__init__.py +1 -0
- specromancy_engine/_cli/cli_commands.py +136 -0
- specromancy_engine/_cli/pipeline_selection.py +28 -0
- specromancy_engine/_config/__init__.py +1 -0
- specromancy_engine/_config/config_errors.py +57 -0
- specromancy_engine/_config/config_fields.py +228 -0
- specromancy_engine/_config/config_loader.py +246 -0
- specromancy_engine/_config/config_models.py +97 -0
- specromancy_engine/_config/config_phase_parser.py +497 -0
- specromancy_engine/_config/config_serialization.py +76 -0
- specromancy_engine/_engine/__init__.py +1 -0
- specromancy_engine/_engine/approval_service.py +278 -0
- specromancy_engine/_engine/approvals.py +248 -0
- specromancy_engine/_engine/command_decisions.py +81 -0
- specromancy_engine/_engine/engine_errors.py +22 -0
- specromancy_engine/_engine/provenance.py +58 -0
- specromancy_engine/_engine/responses.py +129 -0
- specromancy_engine/_engine/validation_service.py +112 -0
- specromancy_engine/_runs/__init__.py +1 -0
- specromancy_engine/_runs/run_errors.py +51 -0
- specromancy_engine/_runs/run_identity.py +66 -0
- specromancy_engine/_runs/run_persistence.py +303 -0
- specromancy_engine/_runs/run_records.py +159 -0
- specromancy_engine/_runs/run_validation.py +241 -0
- specromancy_engine/_runs/visit_preparation.py +61 -0
- specromancy_engine/_runs/visit_transitions.py +311 -0
- specromancy_engine/_version.py +18 -0
- specromancy_engine/actions.py +118 -0
- specromancy_engine/adapters.py +97 -0
- specromancy_engine/artifacts.py +303 -0
- specromancy_engine/cli.py +338 -0
- specromancy_engine/commands.py +140 -0
- specromancy_engine/config.py +63 -0
- specromancy_engine/contracts.py +20 -0
- specromancy_engine/engine.py +423 -0
- specromancy_engine/errors.py +42 -0
- specromancy_engine/exit_codes.py +53 -0
- specromancy_engine/git.py +264 -0
- specromancy_engine/graph.py +321 -0
- specromancy_engine/hashing.py +95 -0
- specromancy_engine/locking.py +107 -0
- specromancy_engine/registry.py +134 -0
- specromancy_engine/run_store.py +643 -0
- specromancy_engine/schema_validation.py +178 -0
- specromancy_engine/schemas/event.schema.json +34 -0
- specromancy_engine/schemas/pipeline.schema.json +154 -0
- specromancy_engine/schemas/pipelines.schema.json +38 -0
- specromancy_engine/schemas/run.schema.json +152 -0
- specromancy_engine/status.py +106 -0
- specromancy_engine/validation.py +241 -0
- specromancy_engine-0.1.3.dist-info/METADATA +122 -0
- specromancy_engine-0.1.3.dist-info/RECORD +63 -0
- specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
- specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
- specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
- specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Canonical SHA-256 helpers and safe relative-path handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
17
|
+
"""Serialize JSON data in the canonical form used by persisted hashes."""
|
|
18
|
+
|
|
19
|
+
return json.dumps(
|
|
20
|
+
value,
|
|
21
|
+
ensure_ascii=False,
|
|
22
|
+
allow_nan=False,
|
|
23
|
+
sort_keys=True,
|
|
24
|
+
separators=(",", ":"),
|
|
25
|
+
).encode("utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sha256_bytes(content: bytes) -> str:
|
|
29
|
+
return hashlib.sha256(content).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha256_text(content: str) -> str:
|
|
33
|
+
return sha256_bytes(content.encode("utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sha256_json(value: Any) -> str:
|
|
37
|
+
return sha256_bytes(canonical_json_bytes(value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sha256_file(path: str | os.PathLike[str]) -> str:
|
|
41
|
+
"""Hash a regular file without normalizing its contents."""
|
|
42
|
+
|
|
43
|
+
digest = hashlib.sha256()
|
|
44
|
+
with Path(path).open("rb") as stream:
|
|
45
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
46
|
+
digest.update(chunk)
|
|
47
|
+
return digest.hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_relative_path(path: str | os.PathLike[str]) -> str:
|
|
51
|
+
"""Return a normalized POSIX path, rejecting absolute and dot paths."""
|
|
52
|
+
|
|
53
|
+
raw = os.fspath(path)
|
|
54
|
+
if not raw or "\\" in raw or raw.startswith("/") or re.match(r"^[A-Za-z]:", raw):
|
|
55
|
+
raise ValueError(f"path must be a non-empty relative POSIX path: {raw!r}")
|
|
56
|
+
pure = PurePosixPath(raw)
|
|
57
|
+
if any(part in {"", ".", ".."} for part in pure.parts):
|
|
58
|
+
raise ValueError(f"path contains a dot segment: {raw!r}")
|
|
59
|
+
normalized = pure.as_posix()
|
|
60
|
+
if normalized != raw:
|
|
61
|
+
raise ValueError(f"path is not normalized: {raw!r}")
|
|
62
|
+
return normalized
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def relative_path(path: Path, root: Path) -> str:
|
|
66
|
+
"""Return a normalized path relative to an already trusted root."""
|
|
67
|
+
|
|
68
|
+
resolved_root = root.resolve()
|
|
69
|
+
resolved = path.resolve()
|
|
70
|
+
if not resolved.is_relative_to(resolved_root):
|
|
71
|
+
raise ValueError(f"path resolves outside root: {path}")
|
|
72
|
+
return normalize_relative_path(resolved.relative_to(resolved_root).as_posix())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_relative_path(
|
|
76
|
+
root: str | os.PathLike[str],
|
|
77
|
+
relative: str | os.PathLike[str],
|
|
78
|
+
*,
|
|
79
|
+
must_exist: bool = False,
|
|
80
|
+
) -> Path:
|
|
81
|
+
"""Resolve a normalized relative path and reject symlink escapes."""
|
|
82
|
+
|
|
83
|
+
normalized = normalize_relative_path(relative)
|
|
84
|
+
resolved_root = Path(root).resolve(strict=True)
|
|
85
|
+
candidate = (resolved_root / normalized).resolve(strict=must_exist)
|
|
86
|
+
if not candidate.is_relative_to(resolved_root):
|
|
87
|
+
raise ValueError(f"path resolves outside root: {normalized!r}")
|
|
88
|
+
return candidate
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Concise aliases are useful at call sites and preserve the public terminology.
|
|
92
|
+
hash_bytes = sha256_bytes
|
|
93
|
+
hash_text = sha256_text
|
|
94
|
+
hash_json = sha256_json
|
|
95
|
+
hash_file = sha256_file
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Per-run lock files implemented with exclusive standard-library creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import SpecromancyError
|
|
13
|
+
from .exit_codes import ExitCode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _utc_now() -> datetime:
|
|
17
|
+
return datetime.now(timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _timestamp(value: datetime) -> str:
|
|
21
|
+
if value.tzinfo is None:
|
|
22
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
23
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LockHeldError(SpecromancyError):
|
|
27
|
+
"""Raised when another process owns a run's lock file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: Path, owner: dict[str, Any] | None = None) -> None:
|
|
30
|
+
details: dict[str, Any] = {
|
|
31
|
+
"lock_path": str(path),
|
|
32
|
+
"remediation": (
|
|
33
|
+
"verify that the recorded process is no longer running, then remove "
|
|
34
|
+
"the lock file manually"
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
if owner is not None:
|
|
38
|
+
details["owner"] = owner
|
|
39
|
+
super().__init__(ExitCode.LOCK_HELD, f"run lock is already held: {path}", details)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunLock:
|
|
43
|
+
"""An exclusive lock that records PID and acquisition time for diagnosis."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
path: str | os.PathLike[str],
|
|
48
|
+
*,
|
|
49
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.path = Path(path)
|
|
52
|
+
self._clock = clock
|
|
53
|
+
self._fd: int | None = None
|
|
54
|
+
|
|
55
|
+
def acquire(self) -> "RunLock":
|
|
56
|
+
if self._fd is not None:
|
|
57
|
+
raise RuntimeError("lock is already acquired by this object")
|
|
58
|
+
payload = {
|
|
59
|
+
"pid": os.getpid(),
|
|
60
|
+
"acquired_at": _timestamp(self._clock()),
|
|
61
|
+
}
|
|
62
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
63
|
+
try:
|
|
64
|
+
descriptor = os.open(self.path, flags, 0o600)
|
|
65
|
+
except FileExistsError as exc:
|
|
66
|
+
raise LockHeldError(self.path, _read_owner(self.path)) from exc
|
|
67
|
+
try:
|
|
68
|
+
content = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
69
|
+
os.write(descriptor, (content + "\n").encode("utf-8"))
|
|
70
|
+
os.fsync(descriptor)
|
|
71
|
+
except BaseException:
|
|
72
|
+
os.close(descriptor)
|
|
73
|
+
self.path.unlink(missing_ok=True)
|
|
74
|
+
raise
|
|
75
|
+
self._fd = descriptor
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def release(self) -> None:
|
|
79
|
+
if self._fd is None:
|
|
80
|
+
return
|
|
81
|
+
descriptor = self._fd
|
|
82
|
+
self._fd = None
|
|
83
|
+
os.close(descriptor)
|
|
84
|
+
try:
|
|
85
|
+
self.path.unlink()
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
# Losing an owned lock is unsafe: another writer could already be active.
|
|
88
|
+
raise RuntimeError(f"owned lock disappeared before release: {self.path}")
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> "RunLock":
|
|
91
|
+
return self.acquire()
|
|
92
|
+
|
|
93
|
+
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
|
94
|
+
self.release()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_owner(path: Path) -> dict[str, Any] | None:
|
|
98
|
+
try:
|
|
99
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
100
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
101
|
+
return None
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
return None
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
FileLock = RunLock
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Repository-owned registrations for independently configured pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NoReturn
|
|
11
|
+
|
|
12
|
+
from ._config.config_errors import PipelineConfigError
|
|
13
|
+
from ._config.config_loader import IDENTIFIER_PATTERN, load_pipeline
|
|
14
|
+
from ._config.config_models import PipelineConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
REGISTRY_PATH = "workflow/pipelines.toml"
|
|
18
|
+
PIPELINE_PATH_PATTERN = re.compile(r"^pipelines/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.toml$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class PipelineRegistration:
|
|
23
|
+
id: str
|
|
24
|
+
path: Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class PipelineRegistry:
|
|
29
|
+
path: Path
|
|
30
|
+
repository_root: Path
|
|
31
|
+
pipelines: tuple[PipelineRegistration, ...]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def canonical_json(self) -> str:
|
|
35
|
+
return json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"schema_version": 1,
|
|
38
|
+
"pipelines": [
|
|
39
|
+
{"id": item.id, "path": item.path.relative_to(self.path.parent).as_posix()}
|
|
40
|
+
for item in self.pipelines
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
sort_keys=True,
|
|
44
|
+
separators=(",", ":"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def load(self, pipeline_id: str) -> PipelineConfig:
|
|
48
|
+
for registration in self.pipelines:
|
|
49
|
+
if registration.id == pipeline_id:
|
|
50
|
+
pipeline = load_pipeline(registration.path, self.repository_root)
|
|
51
|
+
if pipeline.id != registration.id:
|
|
52
|
+
raise PipelineConfigError(
|
|
53
|
+
"pipeline-id-mismatch",
|
|
54
|
+
"registered ID differs from pipeline ID",
|
|
55
|
+
path=self.path,
|
|
56
|
+
field="pipelines.id",
|
|
57
|
+
value=pipeline_id,
|
|
58
|
+
remediation="make the registration and pipeline IDs agree",
|
|
59
|
+
)
|
|
60
|
+
return pipeline
|
|
61
|
+
raise PipelineConfigError(
|
|
62
|
+
"unknown-pipeline",
|
|
63
|
+
f"pipeline {pipeline_id!r} is not registered",
|
|
64
|
+
path=self.path,
|
|
65
|
+
field="pipelines.id",
|
|
66
|
+
value=pipeline_id,
|
|
67
|
+
remediation="choose a registered ID: " + ", ".join(item.id for item in self.pipelines),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def load_all(self) -> tuple[PipelineConfig, ...]:
|
|
71
|
+
return tuple(self.load(item.id) for item in self.pipelines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_registry(repository_root: str | Path) -> PipelineRegistry:
|
|
75
|
+
"""Validate the mandatory registry without loading unrelated phase graphs."""
|
|
76
|
+
|
|
77
|
+
root = Path(repository_root).expanduser().resolve()
|
|
78
|
+
path = root / REGISTRY_PATH
|
|
79
|
+
|
|
80
|
+
def fail(code: str, message: str, field: str, value: object = None) -> NoReturn:
|
|
81
|
+
raise PipelineConfigError(
|
|
82
|
+
code,
|
|
83
|
+
message,
|
|
84
|
+
path=path,
|
|
85
|
+
field=field,
|
|
86
|
+
value=value,
|
|
87
|
+
remediation="use schema_version = 1 and unique [[pipelines]] id/path entries under workflow/pipelines/",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if not path.resolve().is_relative_to(root):
|
|
92
|
+
fail("unsafe-registry-path", "registry resolves outside the repository", "registry")
|
|
93
|
+
with path.open("rb") as stream:
|
|
94
|
+
raw = tomllib.load(stream)
|
|
95
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
96
|
+
fail("invalid-registry", f"cannot load mandatory registry: {exc}", "registry")
|
|
97
|
+
if set(raw) != {"schema_version", "pipelines"}:
|
|
98
|
+
fail("invalid-registry-fields", "registry requires only schema_version and pipelines", "registry")
|
|
99
|
+
if type(raw["schema_version"]) is not int or raw["schema_version"] != 1:
|
|
100
|
+
fail("unsupported-schema-version", "registry schema_version must be 1", "schema_version", raw["schema_version"])
|
|
101
|
+
entries = raw["pipelines"]
|
|
102
|
+
if not isinstance(entries, list) or not entries:
|
|
103
|
+
fail("empty-registry", "pipelines must be a nonempty array of tables", "pipelines")
|
|
104
|
+
registrations: list[PipelineRegistration] = []
|
|
105
|
+
ids: set[str] = set()
|
|
106
|
+
paths: set[Path] = set()
|
|
107
|
+
directory = root / "workflow" / "pipelines"
|
|
108
|
+
for entry in entries:
|
|
109
|
+
if not isinstance(entry, dict) or set(entry) != {"id", "path"}:
|
|
110
|
+
fail("invalid-registration", "registration requires only id and path", "pipelines", entry)
|
|
111
|
+
pipeline_id = entry["id"]
|
|
112
|
+
if not isinstance(pipeline_id, str) or not IDENTIFIER_PATTERN.fullmatch(pipeline_id):
|
|
113
|
+
fail("invalid-identifier", "pipeline ID must be lowercase kebab-case", "pipelines.id", pipeline_id)
|
|
114
|
+
if pipeline_id in ids:
|
|
115
|
+
fail("duplicate-pipeline-id", "pipeline IDs must be unique", "pipelines.id", pipeline_id)
|
|
116
|
+
declared = entry["path"]
|
|
117
|
+
if not isinstance(declared, str):
|
|
118
|
+
fail("unsafe-pipeline-path", "pipeline path must be a relative TOML path", "pipelines.path", declared)
|
|
119
|
+
if not PIPELINE_PATH_PATTERN.fullmatch(declared):
|
|
120
|
+
fail("unsafe-pipeline-path", "pipeline path must be under pipelines/", "pipelines.path", declared)
|
|
121
|
+
try:
|
|
122
|
+
resolved = (path.parent / declared).resolve()
|
|
123
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
124
|
+
fail("unsafe-pipeline-path", f"pipeline path cannot be resolved: {exc}", "pipelines.path", declared)
|
|
125
|
+
if not resolved.is_relative_to(directory) or not resolved.is_relative_to(root):
|
|
126
|
+
fail("unsafe-pipeline-path", "pipeline path escapes workflow/pipelines/", "pipelines.path", declared)
|
|
127
|
+
if not resolved.is_file():
|
|
128
|
+
fail("missing-pipeline", "registered pipeline file does not exist", "pipelines.path", declared)
|
|
129
|
+
if resolved in paths:
|
|
130
|
+
fail("duplicate-pipeline-path", "pipeline paths must be unique", "pipelines.path", declared)
|
|
131
|
+
ids.add(pipeline_id)
|
|
132
|
+
paths.add(resolved)
|
|
133
|
+
registrations.append(PipelineRegistration(pipeline_id, resolved))
|
|
134
|
+
return PipelineRegistry(path, root, tuple(sorted(registrations, key=lambda item: item.id)))
|