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.
Files changed (63) hide show
  1. specromancy_engine/__init__.py +32 -0
  2. specromancy_engine/__main__.py +8 -0
  3. specromancy_engine/_adapters/__init__.py +1 -0
  4. specromancy_engine/_adapters/adapter_contracts.py +81 -0
  5. specromancy_engine/_adapters/adapter_ownership.py +302 -0
  6. specromancy_engine/_adapters/adapter_rendering.py +218 -0
  7. specromancy_engine/_adapters/adapter_sources.py +140 -0
  8. specromancy_engine/_cli/__init__.py +1 -0
  9. specromancy_engine/_cli/cli_commands.py +136 -0
  10. specromancy_engine/_cli/pipeline_selection.py +28 -0
  11. specromancy_engine/_config/__init__.py +1 -0
  12. specromancy_engine/_config/config_errors.py +57 -0
  13. specromancy_engine/_config/config_fields.py +228 -0
  14. specromancy_engine/_config/config_loader.py +246 -0
  15. specromancy_engine/_config/config_models.py +97 -0
  16. specromancy_engine/_config/config_phase_parser.py +497 -0
  17. specromancy_engine/_config/config_serialization.py +76 -0
  18. specromancy_engine/_engine/__init__.py +1 -0
  19. specromancy_engine/_engine/approval_service.py +278 -0
  20. specromancy_engine/_engine/approvals.py +248 -0
  21. specromancy_engine/_engine/command_decisions.py +81 -0
  22. specromancy_engine/_engine/engine_errors.py +22 -0
  23. specromancy_engine/_engine/provenance.py +58 -0
  24. specromancy_engine/_engine/responses.py +129 -0
  25. specromancy_engine/_engine/validation_service.py +112 -0
  26. specromancy_engine/_runs/__init__.py +1 -0
  27. specromancy_engine/_runs/run_errors.py +51 -0
  28. specromancy_engine/_runs/run_identity.py +66 -0
  29. specromancy_engine/_runs/run_persistence.py +303 -0
  30. specromancy_engine/_runs/run_records.py +159 -0
  31. specromancy_engine/_runs/run_validation.py +241 -0
  32. specromancy_engine/_runs/visit_preparation.py +61 -0
  33. specromancy_engine/_runs/visit_transitions.py +311 -0
  34. specromancy_engine/_version.py +18 -0
  35. specromancy_engine/actions.py +118 -0
  36. specromancy_engine/adapters.py +97 -0
  37. specromancy_engine/artifacts.py +303 -0
  38. specromancy_engine/cli.py +338 -0
  39. specromancy_engine/commands.py +140 -0
  40. specromancy_engine/config.py +63 -0
  41. specromancy_engine/contracts.py +20 -0
  42. specromancy_engine/engine.py +423 -0
  43. specromancy_engine/errors.py +42 -0
  44. specromancy_engine/exit_codes.py +53 -0
  45. specromancy_engine/git.py +264 -0
  46. specromancy_engine/graph.py +321 -0
  47. specromancy_engine/hashing.py +95 -0
  48. specromancy_engine/locking.py +107 -0
  49. specromancy_engine/registry.py +134 -0
  50. specromancy_engine/run_store.py +643 -0
  51. specromancy_engine/schema_validation.py +178 -0
  52. specromancy_engine/schemas/event.schema.json +34 -0
  53. specromancy_engine/schemas/pipeline.schema.json +154 -0
  54. specromancy_engine/schemas/pipelines.schema.json +38 -0
  55. specromancy_engine/schemas/run.schema.json +152 -0
  56. specromancy_engine/status.py +106 -0
  57. specromancy_engine/validation.py +241 -0
  58. specromancy_engine-0.1.3.dist-info/METADATA +122 -0
  59. specromancy_engine-0.1.3.dist-info/RECORD +63 -0
  60. specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
  61. specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
  62. specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
  63. 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)))