terminusdb-migrations 0.1.0__tar.gz

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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: terminusdb-migrations
3
+ Version: 0.1.0
4
+ Summary: Schema diff and migration automation for TerminusDB
5
+ Project-URL: Source, https://gitlab.com/ed-tech6370840/terminusdb-migrations
6
+ Project-URL: Issues, https://gitlab.com/ed-tech6370840/terminusdb-migrations/-/issues
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: terminusdb-async<1.0.0,>=0.1.0
10
+ Requires-Dist: terminusdb-pydantic<1.0.0,>=0.1.0
11
+
12
+ # terminusdb-migrations
13
+
14
+ Schema diff and migration planning for TerminusDB based on Pydantic models.
15
+
16
+ ## Development
17
+
18
+ After `terminusdb-async` and `terminusdb-pydantic` 0.1.0 are available from
19
+ PyPI:
20
+
21
+ ```bash
22
+ uv sync --group dev
23
+ uv run pytest
24
+ uv build --no-sources
25
+ ```
26
+
27
+ Dependencies are declared in `pyproject.toml` and managed with uv.
28
+
29
+ The initial CI unit tests intentionally use an isolated uv environment until the
30
+ two runtime packages have completed their first PyPI release.
31
+
32
+ ## Release
33
+
34
+ A version tag such as `v0.1.0` publishes through PyPI Trusted Publishing with
35
+ `uv publish`.
@@ -0,0 +1,24 @@
1
+ # terminusdb-migrations
2
+
3
+ Schema diff and migration planning for TerminusDB based on Pydantic models.
4
+
5
+ ## Development
6
+
7
+ After `terminusdb-async` and `terminusdb-pydantic` 0.1.0 are available from
8
+ PyPI:
9
+
10
+ ```bash
11
+ uv sync --group dev
12
+ uv run pytest
13
+ uv build --no-sources
14
+ ```
15
+
16
+ Dependencies are declared in `pyproject.toml` and managed with uv.
17
+
18
+ The initial CI unit tests intentionally use an isolated uv environment until the
19
+ two runtime packages have completed their first PyPI release.
20
+
21
+ ## Release
22
+
23
+ A version tag such as `v0.1.0` publishes through PyPI Trusted Publishing with
24
+ `uv publish`.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "terminusdb-migrations"
7
+ version = "0.1.0"
8
+ description = "Schema diff and migration automation for TerminusDB"
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+ dependencies = [
12
+ "terminusdb-async>=0.1.0,<1.0.0",
13
+ "terminusdb-pydantic>=0.1.0,<1.0.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Source = "https://gitlab.com/ed-tech6370840/terminusdb-migrations"
18
+ Issues = "https://gitlab.com/ed-tech6370840/terminusdb-migrations/-/issues"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=8",
23
+ "pytest-asyncio>=0.23",
24
+ ]
25
+
26
+ [project.scripts]
27
+ tdb-migrate = "terminusdb_migrations.cli:main"
28
+
29
+ [tool.uv]
30
+ package = true
31
+ trusted-publishing = "always"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .manager import MigrationManager
2
+ from .plan import MigrationPlan
3
+
4
+ __all__ = ["MigrationManager", "MigrationPlan"]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import importlib
6
+ import os
7
+
8
+ from terminusdb_async import AsyncTerminusClient
9
+
10
+ from .manager import MigrationManager
11
+
12
+
13
+ def _load_models(spec: str):
14
+ module_name, attr = spec.split(":", 1)
15
+ module = importlib.import_module(module_name)
16
+ return getattr(module, attr)
17
+
18
+
19
+ async def _run(args):
20
+ models = _load_models(args.models)
21
+ async with AsyncTerminusClient(
22
+ args.url,
23
+ organization=args.org,
24
+ database=args.db,
25
+ branch=args.branch,
26
+ username=args.username,
27
+ password=args.password,
28
+ token=args.token,
29
+ ) as client:
30
+ manager = MigrationManager(client, models)
31
+ plan = await manager.plan()
32
+ print(plan.render())
33
+ if args.command == "apply":
34
+ await manager.apply(
35
+ plan,
36
+ author=args.author,
37
+ message=args.message,
38
+ dry_run=args.dry_run,
39
+ allow_destructive=args.allow_destructive,
40
+ )
41
+
42
+
43
+ def main():
44
+ parser = argparse.ArgumentParser(prog="tdb-migrate")
45
+ parser.add_argument("command", choices=["plan", "apply"])
46
+ parser.add_argument("--models", required=True, help="Python import path, e.g. app.models:MODELS")
47
+ parser.add_argument("--url", default=os.getenv("TERMINUSDB_URL", "http://localhost:6363"))
48
+ parser.add_argument("--org", default=os.getenv("TERMINUSDB_ORG", "admin"))
49
+ parser.add_argument("--db", required=True)
50
+ parser.add_argument("--branch", default="main")
51
+ parser.add_argument("--username", default=os.getenv("TERMINUSDB_USER"))
52
+ parser.add_argument("--password", default=os.getenv("TERMINUSDB_PASSWORD"))
53
+ parser.add_argument("--token", default=os.getenv("TERMINUSDB_TOKEN"))
54
+ parser.add_argument("--author", default="schema-bot")
55
+ parser.add_argument("--message", default="sync schema")
56
+ parser.add_argument("--dry-run", action="store_true")
57
+ parser.add_argument("--allow-destructive", action="store_true")
58
+ args = parser.parse_args()
59
+ asyncio.run(_run(args))
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from .plan import MigrationPlan
9
+
10
+ if TYPE_CHECKING:
11
+ from terminusdb_async import AsyncTerminusClient
12
+
13
+
14
+ class MigrationManager:
15
+ def __init__(self, client: "AsyncTerminusClient", models: Iterable[type[BaseModel]]):
16
+ self.client = client
17
+ self.models = list(models)
18
+
19
+ async def plan(self) -> MigrationPlan:
20
+ from terminusdb_pydantic import models_to_schema
21
+
22
+ current = await self.client.get_schema_documents()
23
+ desired = models_to_schema(self.models)
24
+ return diff_schema(current, desired)
25
+
26
+ async def apply(
27
+ self,
28
+ plan: MigrationPlan,
29
+ *,
30
+ author: str,
31
+ message: str,
32
+ dry_run: bool = False,
33
+ allow_destructive: bool = False,
34
+ ) -> Any:
35
+ if plan.destructive and not allow_destructive:
36
+ raise RuntimeError("Migration contains destructive changes; pass allow_destructive=True")
37
+
38
+ results: list[Any] = []
39
+ if plan.schema_documents:
40
+ if dry_run:
41
+ results.append({"schema_documents": plan.schema_documents, "dry_run": True})
42
+ else:
43
+ results.append(
44
+ await self.client.insert_schema_documents(
45
+ plan.schema_documents,
46
+ author=author,
47
+ message=message,
48
+ )
49
+ )
50
+ if plan.operations:
51
+ results.append(
52
+ await self.client.migrate(
53
+ plan.operations,
54
+ author=author,
55
+ message=message,
56
+ dry_run=dry_run,
57
+ )
58
+ )
59
+ return results
60
+
61
+
62
+ def diff_schema(current: list[dict[str, Any]], desired: list[dict[str, Any]]) -> MigrationPlan:
63
+ plan = MigrationPlan()
64
+ current_map = {d.get("@id"): d for d in current if d.get("@id")}
65
+ desired_map = {d.get("@id"): d for d in desired if d.get("@id")}
66
+
67
+ for ident, target in desired_map.items():
68
+ source = current_map.get(ident)
69
+ if source is None:
70
+ if target.get("@type") == "Enum":
71
+ plan.schema_documents.append(target)
72
+ else:
73
+ plan.operations.append({"@type": "CreateClass", "class": target})
74
+ continue
75
+
76
+ if target.get("@type") == "Enum" and source.get("@type") == "Enum":
77
+ old_values = list(source.get("@value", []))
78
+ new_values = list(target.get("@value", []))
79
+ added = [v for v in new_values if v not in old_values]
80
+ removed = [v for v in old_values if v not in new_values]
81
+ if added:
82
+ plan.operations.append({"@type": "ExpandEnum", "enum": ident, "values": added})
83
+ if removed:
84
+ plan.warnings.append(f"Enum {ident} removes values {removed}; manual migration required")
85
+ plan.destructive = True
86
+ continue
87
+
88
+ if target.get("@type") != "Class" or source.get("@type") != "Class":
89
+ continue
90
+
91
+ _diff_class(ident, source, target, plan)
92
+
93
+ for ident, source in current_map.items():
94
+ if ident not in desired_map and source.get("@type") in {"Class", "Enum"}:
95
+ plan.warnings.append(f"{source.get('@type')} {ident} exists in DB but not in models")
96
+ plan.destructive = True
97
+
98
+ return plan
99
+
100
+
101
+ def _diff_class(name: str, source: dict[str, Any], target: dict[str, Any], plan: MigrationPlan) -> None:
102
+ metadata = {"@type", "@id", "@key", "@inherits", "@documentation", "@abstract", "@subdocument"}
103
+ old_props = {k: v for k, v in source.items() if k not in metadata}
104
+ new_props = {k: v for k, v in target.items() if k not in metadata}
105
+
106
+ for prop, prop_type in new_props.items():
107
+ if prop not in old_props:
108
+ if _is_optional(prop_type):
109
+ plan.operations.append(
110
+ {"@type": "CreateClassProperty", "class": name, "property": prop, "type": prop_type}
111
+ )
112
+ else:
113
+ plan.warnings.append(
114
+ f"{name}.{prop} is a new required property without a migration default"
115
+ )
116
+ continue
117
+
118
+ if old_props[prop] != prop_type:
119
+ plan.operations.append(
120
+ {
121
+ "@type": "CastClassProperty",
122
+ "class": name,
123
+ "property": prop,
124
+ "type": prop_type,
125
+ }
126
+ )
127
+
128
+ for prop in old_props:
129
+ if prop not in new_props:
130
+ plan.warnings.append(f"{name}.{prop} would be removed")
131
+ plan.destructive = True
132
+
133
+ if source.get("@key") != target.get("@key"):
134
+ plan.operations.append(
135
+ {"@type": "ChangeKey", "class": name, "key": target.get("@key", {"@type": "Random"})}
136
+ )
137
+ plan.destructive = True
138
+
139
+
140
+ def _is_optional(value: Any) -> bool:
141
+ return isinstance(value, dict) and value.get("@type") == "Optional"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class MigrationPlan:
9
+ operations: list[dict[str, Any]] = field(default_factory=list)
10
+ schema_documents: list[dict[str, Any]] = field(default_factory=list)
11
+ warnings: list[str] = field(default_factory=list)
12
+ destructive: bool = False
13
+
14
+ @property
15
+ def empty(self) -> bool:
16
+ return not self.operations and not self.schema_documents
17
+
18
+ def render(self) -> str:
19
+ lines: list[str] = []
20
+ for doc in self.schema_documents:
21
+ lines.append(f"+ schema {doc.get('@type')} {doc.get('@id')}")
22
+ for op in self.operations:
23
+ lines.append(f"+ {op.get('@type')}: {op}")
24
+ if self.warnings:
25
+ lines.append("")
26
+ lines.extend(f"! {warning}" for warning in self.warnings)
27
+ if not lines:
28
+ return "No schema changes."
29
+ return "\n".join(lines)
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: terminusdb-migrations
3
+ Version: 0.1.0
4
+ Summary: Schema diff and migration automation for TerminusDB
5
+ Project-URL: Source, https://gitlab.com/ed-tech6370840/terminusdb-migrations
6
+ Project-URL: Issues, https://gitlab.com/ed-tech6370840/terminusdb-migrations/-/issues
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: terminusdb-async<1.0.0,>=0.1.0
10
+ Requires-Dist: terminusdb-pydantic<1.0.0,>=0.1.0
11
+
12
+ # terminusdb-migrations
13
+
14
+ Schema diff and migration planning for TerminusDB based on Pydantic models.
15
+
16
+ ## Development
17
+
18
+ After `terminusdb-async` and `terminusdb-pydantic` 0.1.0 are available from
19
+ PyPI:
20
+
21
+ ```bash
22
+ uv sync --group dev
23
+ uv run pytest
24
+ uv build --no-sources
25
+ ```
26
+
27
+ Dependencies are declared in `pyproject.toml` and managed with uv.
28
+
29
+ The initial CI unit tests intentionally use an isolated uv environment until the
30
+ two runtime packages have completed their first PyPI release.
31
+
32
+ ## Release
33
+
34
+ A version tag such as `v0.1.0` publishes through PyPI Trusted Publishing with
35
+ `uv publish`.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/terminusdb_migrations/__init__.py
4
+ src/terminusdb_migrations/cli.py
5
+ src/terminusdb_migrations/manager.py
6
+ src/terminusdb_migrations/plan.py
7
+ src/terminusdb_migrations.egg-info/PKG-INFO
8
+ src/terminusdb_migrations.egg-info/SOURCES.txt
9
+ src/terminusdb_migrations.egg-info/dependency_links.txt
10
+ src/terminusdb_migrations.egg-info/entry_points.txt
11
+ src/terminusdb_migrations.egg-info/requires.txt
12
+ src/terminusdb_migrations.egg-info/top_level.txt
13
+ tests/test_diff.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tdb-migrate = terminusdb_migrations.cli:main
@@ -0,0 +1,2 @@
1
+ terminusdb-async<1.0.0,>=0.1.0
2
+ terminusdb-pydantic<1.0.0,>=0.1.0
@@ -0,0 +1,45 @@
1
+ from terminusdb_migrations.manager import diff_schema
2
+
3
+
4
+ def test_add_optional_property():
5
+ current = [
6
+ {
7
+ "@type": "Class",
8
+ "@id": "Discipline",
9
+ "@key": {"@type": "Random"},
10
+ "name": "xsd:string",
11
+ }
12
+ ]
13
+ desired = [
14
+ {
15
+ "@type": "Class",
16
+ "@id": "Discipline",
17
+ "@key": {"@type": "Random"},
18
+ "name": "xsd:string",
19
+ "description": {"@type": "Optional", "@class": "xsd:string"},
20
+ }
21
+ ]
22
+ plan = diff_schema(current, desired)
23
+ assert plan.operations == [
24
+ {
25
+ "@type": "CreateClassProperty",
26
+ "class": "Discipline",
27
+ "property": "description",
28
+ "type": {"@type": "Optional", "@class": "xsd:string"},
29
+ }
30
+ ]
31
+
32
+
33
+ def test_expand_enum():
34
+ current = [{"@type": "Enum", "@id": "Level", "@value": ["bachelor"]}]
35
+ desired = [{"@type": "Enum", "@id": "Level", "@value": ["bachelor", "master"]}]
36
+ plan = diff_schema(current, desired)
37
+ assert plan.operations[0]["@type"] == "ExpandEnum"
38
+ assert plan.operations[0]["values"] == ["master"]
39
+
40
+
41
+ def test_remove_property_is_destructive():
42
+ current = [{"@type": "Class", "@id": "X", "@key": {"@type": "Random"}, "a": "xsd:string"}]
43
+ desired = [{"@type": "Class", "@id": "X", "@key": {"@type": "Random"}}]
44
+ plan = diff_schema(current, desired)
45
+ assert plan.destructive