Initial SFERA platform baseline

This commit is contained in:
2026-05-16 19:03:49 +03:00
commit 3b845c8fce
282 changed files with 55045 additions and 0 deletions
+11
View File
@@ -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"]