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.
- terminusdb_migrations-0.1.0/PKG-INFO +35 -0
- terminusdb_migrations-0.1.0/README.md +24 -0
- terminusdb_migrations-0.1.0/pyproject.toml +34 -0
- terminusdb_migrations-0.1.0/setup.cfg +4 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations/__init__.py +4 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations/cli.py +63 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations/manager.py +141 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations/plan.py +29 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/PKG-INFO +35 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/SOURCES.txt +13 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/dependency_links.txt +1 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/entry_points.txt +2 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/requires.txt +2 -0
- terminusdb_migrations-0.1.0/src/terminusdb_migrations.egg-info/top_level.txt +1 -0
- terminusdb_migrations-0.1.0/tests/test_diff.py +45 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
terminusdb_migrations
|
|
@@ -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
|