Initial SFERA platform baseline
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# sfera-semantic-versioning
|
||||
|
||||
Object-level semantic version store.
|
||||
|
||||
Provides:
|
||||
|
||||
- immutable `SemanticObjectVersion` records per SIR lineage;
|
||||
- snapshot-to-version materialization;
|
||||
- task/session metadata on object versions;
|
||||
- deterministic payload hashes and duplicate suppression;
|
||||
- JSON-path semantic diffs between two versions.
|
||||
@@ -0,0 +1,11 @@
|
||||
[project]
|
||||
name = "sfera-semantic-versioning"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"sfera-sir",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sir import SemanticNode, SirSnapshot, stable_hash
|
||||
|
||||
|
||||
class SemanticObjectVersion(BaseModel):
|
||||
version_id: str
|
||||
lineage_id: str
|
||||
semantic_id: str
|
||||
object_hash: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
task_id: str | None = None
|
||||
session_id: str | None = None
|
||||
parent_version_id: str | None = None
|
||||
payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SemanticObjectDiffEntry(BaseModel):
|
||||
path: str
|
||||
kind: str
|
||||
before: object | None = None
|
||||
after: object | None = None
|
||||
|
||||
|
||||
class SemanticObjectDiff(BaseModel):
|
||||
from_version_id: str
|
||||
to_version_id: str
|
||||
lineage_id: str
|
||||
changed: bool
|
||||
entries: list[SemanticObjectDiffEntry] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SemanticObjectChange(BaseModel):
|
||||
change_kind: str
|
||||
diff: SemanticObjectDiff
|
||||
|
||||
|
||||
class InMemoryObjectVersionStore:
|
||||
def __init__(self) -> None:
|
||||
self._versions_by_lineage: dict[str, list[SemanticObjectVersion]] = {}
|
||||
|
||||
def append_node_version(
|
||||
self,
|
||||
node: SemanticNode,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> SemanticObjectVersion:
|
||||
previous = self.latest(node.lineage_id)
|
||||
payload = node.model_dump(mode="json")
|
||||
object_hash = stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
version = SemanticObjectVersion(
|
||||
version_id=f"version.{node.lineage_id}.{object_hash}",
|
||||
lineage_id=node.lineage_id,
|
||||
semantic_id=node.semantic_id,
|
||||
object_hash=object_hash,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
parent_version_id=previous.version_id if previous else None,
|
||||
payload=payload,
|
||||
)
|
||||
versions = self._versions_by_lineage.setdefault(node.lineage_id, [])
|
||||
if not versions or versions[-1].object_hash != object_hash:
|
||||
versions.append(version)
|
||||
return versions[-1]
|
||||
|
||||
def append_snapshot_versions(
|
||||
self,
|
||||
snapshot: SirSnapshot,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> list[SemanticObjectVersion]:
|
||||
return [
|
||||
self.append_node_version(node, task_id=task_id, session_id=session_id)
|
||||
for node in snapshot.nodes
|
||||
]
|
||||
|
||||
def upsert_version(self, version: SemanticObjectVersion) -> SemanticObjectVersion:
|
||||
versions = self._versions_by_lineage.setdefault(version.lineage_id, [])
|
||||
if all(existing.version_id != version.version_id for existing in versions):
|
||||
versions.append(version)
|
||||
versions.sort(key=lambda item: item.created_at)
|
||||
return version
|
||||
|
||||
def latest(self, lineage_id: str) -> SemanticObjectVersion | None:
|
||||
versions = self._versions_by_lineage.get(lineage_id, [])
|
||||
return versions[-1] if versions else None
|
||||
|
||||
def find_version(self, version_id: str) -> SemanticObjectVersion | None:
|
||||
return next((version for version in self.all_versions() if version.version_id == version_id), None)
|
||||
|
||||
def history(self, lineage_id: str) -> list[SemanticObjectVersion]:
|
||||
return list(self._versions_by_lineage.get(lineage_id, []))
|
||||
|
||||
def all_versions(self) -> list[SemanticObjectVersion]:
|
||||
return [
|
||||
version
|
||||
for versions in self._versions_by_lineage.values()
|
||||
for version in versions
|
||||
]
|
||||
|
||||
|
||||
def diff_versions(
|
||||
before: SemanticObjectVersion,
|
||||
after: SemanticObjectVersion,
|
||||
) -> SemanticObjectDiff:
|
||||
if before.lineage_id != after.lineage_id:
|
||||
raise ValueError("Cannot diff versions from different lineage_id values")
|
||||
entries = _diff_payload("", before.payload, after.payload)
|
||||
return SemanticObjectDiff(
|
||||
from_version_id=before.version_id,
|
||||
to_version_id=after.version_id,
|
||||
lineage_id=before.lineage_id,
|
||||
changed=bool(entries),
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
def classify_version_change(
|
||||
before: SemanticObjectVersion,
|
||||
after: SemanticObjectVersion,
|
||||
) -> SemanticObjectChange:
|
||||
diff = diff_versions(before, after)
|
||||
if not diff.changed:
|
||||
change_kind = "UNCHANGED"
|
||||
elif _object_parent(before.payload.get("qualified_name")) != _object_parent(after.payload.get("qualified_name")):
|
||||
change_kind = "MOVE"
|
||||
elif before.payload.get("name") != after.payload.get("name"):
|
||||
change_kind = "RENAME"
|
||||
else:
|
||||
change_kind = "UPDATE"
|
||||
return SemanticObjectChange(change_kind=change_kind, diff=diff)
|
||||
|
||||
|
||||
def _object_parent(qualified_name: object) -> str:
|
||||
if not isinstance(qualified_name, str) or "." not in qualified_name:
|
||||
return ""
|
||||
return qualified_name.rsplit(".", 1)[0]
|
||||
|
||||
|
||||
def _diff_payload(path: str, before: object, after: object) -> list[SemanticObjectDiffEntry]:
|
||||
if isinstance(before, dict) and isinstance(after, dict):
|
||||
entries: list[SemanticObjectDiffEntry] = []
|
||||
for key in sorted(set(before) | set(after)):
|
||||
child_path = f"{path}.{key}" if path else str(key)
|
||||
if key not in before:
|
||||
entries.append(SemanticObjectDiffEntry(path=child_path, kind="ADD", after=after[key]))
|
||||
elif key not in after:
|
||||
entries.append(SemanticObjectDiffEntry(path=child_path, kind="REMOVE", before=before[key]))
|
||||
else:
|
||||
entries.extend(_diff_payload(child_path, before[key], after[key]))
|
||||
return entries
|
||||
if before == after:
|
||||
return []
|
||||
return [SemanticObjectDiffEntry(path=path or "$", kind="CHANGE", before=before, after=after)]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InMemoryObjectVersionStore",
|
||||
"SemanticObjectDiff",
|
||||
"SemanticObjectDiffEntry",
|
||||
"SemanticObjectChange",
|
||||
"SemanticObjectVersion",
|
||||
"classify_version_change",
|
||||
"diff_versions",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
from semantic_versioning import InMemoryObjectVersionStore, SemanticObjectVersion, classify_version_change, diff_versions
|
||||
from sir import NodeKind, SemanticNode, SirSnapshot, SourceRef
|
||||
|
||||
|
||||
def test_object_version_store_deduplicates_same_payload():
|
||||
node = SemanticNode(
|
||||
semantic_id="procedure.demo",
|
||||
lineage_id="lineage.procedure.demo",
|
||||
kind=NodeKind.PROCEDURE,
|
||||
name="Demo",
|
||||
qualified_name="Module.Demo",
|
||||
source_ref=SourceRef(source_path="module.bsl"),
|
||||
)
|
||||
store = InMemoryObjectVersionStore()
|
||||
|
||||
first = store.append_node_version(node)
|
||||
second = store.append_node_version(node)
|
||||
|
||||
assert first.version_id == second.version_id
|
||||
assert len(store.history(node.lineage_id)) == 1
|
||||
|
||||
|
||||
def test_object_version_store_versions_snapshot_nodes():
|
||||
node = SemanticNode(
|
||||
semantic_id="procedure.demo",
|
||||
lineage_id="lineage.procedure.demo",
|
||||
kind=NodeKind.PROCEDURE,
|
||||
name="Demo",
|
||||
qualified_name="Module.Demo",
|
||||
source_ref=SourceRef(source_path="module.bsl"),
|
||||
)
|
||||
snapshot = SirSnapshot(snapshot_id="snapshot.demo", project_id="demo", nodes=[node])
|
||||
store = InMemoryObjectVersionStore()
|
||||
|
||||
versions = store.append_snapshot_versions(snapshot, task_id="task.1")
|
||||
|
||||
assert versions[0].task_id == "task.1"
|
||||
assert store.all_versions()[0].lineage_id == node.lineage_id
|
||||
|
||||
|
||||
def test_object_version_diff_reports_payload_changes():
|
||||
before = SemanticObjectVersion(
|
||||
version_id="version.1",
|
||||
lineage_id="lineage.procedure.demo",
|
||||
semantic_id="procedure.demo",
|
||||
object_hash="hash.1",
|
||||
payload={"name": "Demo", "attributes": {"export": False, "source": "old"}},
|
||||
)
|
||||
after = SemanticObjectVersion(
|
||||
version_id="version.2",
|
||||
lineage_id="lineage.procedure.demo",
|
||||
semantic_id="procedure.demo",
|
||||
object_hash="hash.2",
|
||||
payload={"name": "Demo", "attributes": {"export": True}, "kind": "PROCEDURE"},
|
||||
)
|
||||
|
||||
diff = diff_versions(before, after)
|
||||
|
||||
assert diff.changed is True
|
||||
assert [(entry.path, entry.kind) for entry in diff.entries] == [
|
||||
("attributes.export", "CHANGE"),
|
||||
("attributes.source", "REMOVE"),
|
||||
("kind", "ADD"),
|
||||
]
|
||||
|
||||
|
||||
def test_object_version_store_finds_version_by_id():
|
||||
node = SemanticNode(
|
||||
semantic_id="procedure.demo",
|
||||
lineage_id="lineage.procedure.demo",
|
||||
kind=NodeKind.PROCEDURE,
|
||||
name="Demo",
|
||||
qualified_name="Module.Demo",
|
||||
source_ref=SourceRef(source_path="module.bsl"),
|
||||
)
|
||||
store = InMemoryObjectVersionStore()
|
||||
version = store.append_node_version(node)
|
||||
|
||||
assert store.find_version(version.version_id) == version
|
||||
|
||||
|
||||
def test_classify_version_change_detects_object_rename():
|
||||
before = SemanticObjectVersion(
|
||||
version_id="version.1",
|
||||
lineage_id="lineage.document.order",
|
||||
semantic_id="document.order",
|
||||
object_hash="hash.1",
|
||||
payload={"name": "ЗаказПокупателя", "qualified_name": "Документ.ЗаказПокупателя"},
|
||||
)
|
||||
after = SemanticObjectVersion(
|
||||
version_id="version.2",
|
||||
lineage_id="lineage.document.order",
|
||||
semantic_id="document.order",
|
||||
object_hash="hash.2",
|
||||
payload={"name": "ЗаказКлиента", "qualified_name": "Документ.ЗаказКлиента"},
|
||||
)
|
||||
|
||||
change = classify_version_change(before, after)
|
||||
|
||||
assert change.change_kind == "RENAME"
|
||||
assert {entry.path for entry in change.diff.entries} == {"name", "qualified_name"}
|
||||
|
||||
|
||||
def test_classify_version_change_detects_object_move():
|
||||
before = SemanticObjectVersion(
|
||||
version_id="version.1",
|
||||
lineage_id="lineage.form.order",
|
||||
semantic_id="form.order",
|
||||
object_hash="hash.1",
|
||||
payload={"name": "ФормаДокумента", "qualified_name": "Документ.ЗаказПокупателя.ФормаДокумента"},
|
||||
)
|
||||
after = SemanticObjectVersion(
|
||||
version_id="version.2",
|
||||
lineage_id="lineage.form.order",
|
||||
semantic_id="form.order",
|
||||
object_hash="hash.2",
|
||||
payload={"name": "ФормаДокумента", "qualified_name": "Документ.ЗаказКлиента.ФормаДокумента"},
|
||||
)
|
||||
|
||||
change = classify_version_change(before, after)
|
||||
|
||||
assert change.change_kind == "MOVE"
|
||||
assert [entry.path for entry in change.diff.entries] == ["qualified_name"]
|
||||
Reference in New Issue
Block a user