modelsync 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.
- modelsync-0.1.0/LICENSE +21 -0
- modelsync-0.1.0/PKG-INFO +178 -0
- modelsync-0.1.0/README.md +152 -0
- modelsync-0.1.0/pyproject.toml +52 -0
- modelsync-0.1.0/setup.cfg +4 -0
- modelsync-0.1.0/src/modelsync/__init__.py +24 -0
- modelsync-0.1.0/src/modelsync/adapters/__init__.py +13 -0
- modelsync-0.1.0/src/modelsync/adapters/model_schema.py +218 -0
- modelsync-0.1.0/src/modelsync/adapters/sa_to_neutral.py +90 -0
- modelsync-0.1.0/src/modelsync/cli.py +378 -0
- modelsync-0.1.0/src/modelsync/compare/__init__.py +16 -0
- modelsync-0.1.0/src/modelsync/compare/db_schema.py +77 -0
- modelsync-0.1.0/src/modelsync/compare/diff.py +249 -0
- modelsync-0.1.0/src/modelsync/errors.py +28 -0
- modelsync-0.1.0/src/modelsync/internal/__init__.py +38 -0
- modelsync-0.1.0/src/modelsync/internal/objects.py +144 -0
- modelsync-0.1.0/src/modelsync/internal/types.py +60 -0
- modelsync-0.1.0/src/modelsync/plan/__init__.py +26 -0
- modelsync-0.1.0/src/modelsync/plan/builder.py +241 -0
- modelsync-0.1.0/src/modelsync/plan/steps.py +92 -0
- modelsync-0.1.0/src/modelsync/schema/__init__.py +36 -0
- modelsync-0.1.0/src/modelsync/schema/db_schema.py +7 -0
- modelsync-0.1.0/src/modelsync/schema/diff.py +10 -0
- modelsync-0.1.0/src/modelsync/schema/model_schema.py +8 -0
- modelsync-0.1.0/src/modelsync/schema/objects.py +23 -0
- modelsync-0.1.0/src/modelsync/schema/sa_to_neutral.py +7 -0
- modelsync-0.1.0/src/modelsync/sql_dialect/__init__.py +16 -0
- modelsync-0.1.0/src/modelsync/sql_dialect/base.py +239 -0
- modelsync-0.1.0/src/modelsync/sql_dialect/postgresql.py +284 -0
- modelsync-0.1.0/src/modelsync/sql_dialect/sqlite.py +142 -0
- modelsync-0.1.0/src/modelsync/sync.py +302 -0
- modelsync-0.1.0/src/modelsync.egg-info/PKG-INFO +178 -0
- modelsync-0.1.0/src/modelsync.egg-info/SOURCES.txt +35 -0
- modelsync-0.1.0/src/modelsync.egg-info/dependency_links.txt +1 -0
- modelsync-0.1.0/src/modelsync.egg-info/entry_points.txt +2 -0
- modelsync-0.1.0/src/modelsync.egg-info/requires.txt +11 -0
- modelsync-0.1.0/src/modelsync.egg-info/top_level.txt +1 -0
modelsync-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Brian L. Pond
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
modelsync-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modelsync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronize database models with actuals — document-driven project.
|
|
5
|
+
Author: Brian L. Pond
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/brian-pond/modelsync
|
|
8
|
+
Project-URL: Repository, https://github.com/brian-pond/modelsync
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff; extra == "dev"
|
|
21
|
+
Requires-Dist: sqlmodel; extra == "dev"
|
|
22
|
+
Requires-Dist: typer>=0.9; extra == "dev"
|
|
23
|
+
Provides-Extra: postgres
|
|
24
|
+
Requires-Dist: psycopg[binary]>=3; extra == "postgres"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# modelsync
|
|
28
|
+
|
|
29
|
+
A Python library for schema and model synchronization: keep your database schema in sync with your SQLAlchemy or SQLModel definitions.
|
|
30
|
+
|
|
31
|
+
## Prerequisites
|
|
32
|
+
|
|
33
|
+
- Python 3.11+
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
Create a virtual environment and install the package in editable mode:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python3 -m venv .venv
|
|
41
|
+
source .venv/bin/activate # Linux/macOS
|
|
42
|
+
# .venv\Scripts\activate # Windows
|
|
43
|
+
pip install -e ".[dev]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Installing in other projects
|
|
47
|
+
|
|
48
|
+
To use modelsync in another Python application:
|
|
49
|
+
|
|
50
|
+
- **Development (same machine):** From your other project, run `pip install -e /path/to/modelsync` or `uv pip install -e /path/to/modelsync`. Changes in the modelsync repo are reflected immediately.
|
|
51
|
+
- **Built wheel:** From the modelsync repo run `uv build` (requires `uv` and dev deps: `pip install -e ".[dev]"`). This produces a wheel in `dist/` (e.g. `dist/modelsync-0.1.0-py3-none-any.whl`). In your other project: `pip install /path/to/modelsync/dist/modelsync-0.1.0-py3-none-any.whl`.
|
|
52
|
+
- **Private index:** Upload the contents of `dist/` to your index; then `pip install modelsync --index-url https://your-index/simple/`.
|
|
53
|
+
|
|
54
|
+
## Running tests
|
|
55
|
+
|
|
56
|
+
From the project root, with the virtual environment activated:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pytest tests/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run only unit tests or only integration tests:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pytest tests/unit/
|
|
66
|
+
pytest tests/integration/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
See `tests/TESTS_README.md` for how tests are organized.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Use modelsync as a library: define your models (e.g. SQLAlchemy or SQLModel), then compare them to the database. By default, only a plan is produced; apply only when you explicitly opt in.
|
|
74
|
+
|
|
75
|
+
### ModelSync: the three main entry points
|
|
76
|
+
|
|
77
|
+
**1. `ModelSync(...)` — set up the connection**
|
|
78
|
+
|
|
79
|
+
Create a `ModelSync` instance by passing either:
|
|
80
|
+
|
|
81
|
+
- **`credentials={"url": "sqlite:///./mydb.sqlite"}`** — modelsync will open the database, run your call, then close it. No need to manage the connection yourself.
|
|
82
|
+
- **`connection=engine.connect()`** — you provide an open connection; you are responsible for closing it when done.
|
|
83
|
+
|
|
84
|
+
For databases that use schemas (e.g. PostgreSQL), also pass **`target_schema="public"`** (or your schema name). For SQLite you can omit it.
|
|
85
|
+
|
|
86
|
+
**2. `compare(models)` — see what would change (dry run)**
|
|
87
|
+
|
|
88
|
+
Pass one model class or a list of model classes. modelsync compares their combined schema to the live database and returns a **plan** of steps (create table, add column, add constraint, etc.) without executing anything. Use this to inspect changes, log them, or generate a DDL script with `plan.sql()`. Returns a `SyncPlan` or a `SyncError` if something went wrong.
|
|
89
|
+
|
|
90
|
+
**3. `do_sync(models)` — apply the changes**
|
|
91
|
+
|
|
92
|
+
Same comparison as `compare()`, but **runs** the plan against the database. By default all steps run in one transaction: if any step fails, everything is rolled back. Returns the applied `SyncPlan` on success or a `SyncError` on failure. Optional: `commit_per_step=True` to commit after each step (partial progress on failure), or `log_file="path"` to append applied steps to a file.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
Define one or more models and pass them (single class or a list) to `compare()` or `do_sync()`. The example below uses `compare()` to get a plan.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from sqlalchemy import Column, Float, ForeignKey, Integer, String
|
|
100
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
101
|
+
|
|
102
|
+
from modelsync import ModelSync, SyncError
|
|
103
|
+
|
|
104
|
+
class Product(DeclarativeBase):
|
|
105
|
+
__tablename__ = "product"
|
|
106
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
107
|
+
name = Column(String(255), nullable=False)
|
|
108
|
+
price = Column(Float, nullable=False)
|
|
109
|
+
|
|
110
|
+
class Cart(DeclarativeBase):
|
|
111
|
+
__tablename__ = "cart"
|
|
112
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
113
|
+
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
|
|
114
|
+
quantity = Column(Integer, nullable=False)
|
|
115
|
+
|
|
116
|
+
sync = ModelSync(credentials={"url": "sqlite:///./mydb.sqlite"})
|
|
117
|
+
result_plan = sync.compare([Product, Cart])
|
|
118
|
+
|
|
119
|
+
if isinstance(result_plan, SyncError):
|
|
120
|
+
print("Compare failed:", result_plan.messages)
|
|
121
|
+
else:
|
|
122
|
+
if not result_plan.steps:
|
|
123
|
+
print("Your target database is up to date.")
|
|
124
|
+
else:
|
|
125
|
+
for step in result_plan.steps:
|
|
126
|
+
print(step)
|
|
127
|
+
# result_plan.sql() returns the full DDL script for inspection or manual execution
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Using your own connection** — you open the connection and close it yourself:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from sqlalchemy import create_engine
|
|
134
|
+
|
|
135
|
+
engine = create_engine("sqlite:///./mydb.sqlite")
|
|
136
|
+
with engine.connect() as conn:
|
|
137
|
+
sync = ModelSync(connection=conn)
|
|
138
|
+
result_plan = sync.compare([Product, Cart])
|
|
139
|
+
engine.dispose()
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Possible Outcomes
|
|
143
|
+
|
|
144
|
+
#### Scenario 1
|
|
145
|
+
If the target database is empty, printing each step might show:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
Create table product
|
|
149
|
+
Create table cart
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
#### Scenario 2
|
|
153
|
+
If the database already matches your models, you'll see *Your target database is up to date.* and no steps.
|
|
154
|
+
|
|
155
|
+
#### Scenario 3
|
|
156
|
+
What if `cart` already exists in the database but is missing the `quantity` column? Then you might see:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
Add column quantity to cart
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
To get the full DDL script as a single string, use `result_plan.sql()`.
|
|
163
|
+
|
|
164
|
+
### Applying the plan with do_sync()
|
|
165
|
+
|
|
166
|
+
To compare and apply the plan in one go (run the DDL against the database), use `do_sync()`. It uses the same comparison as `compare()` but executes the steps in a single transaction (all-or-nothing; rollback on failure). Returns the applied `SyncPlan` on success or `SyncError` on failure.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
result_plan = sync.do_sync([Product, Cart])
|
|
170
|
+
|
|
171
|
+
if isinstance(result_plan, SyncError):
|
|
172
|
+
print("Sync failed:", result_plan.messages)
|
|
173
|
+
else:
|
|
174
|
+
print(f"Applied {len(result_plan.steps)} step(s). Schema is now in sync.")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Optional: `do_sync(..., commit_per_step=True)` commits after each step so partial progress is kept if a later step fails. `do_sync(..., log_file="/path/to/sync.log")` appends applied steps as JSON lines to a file.
|
|
178
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# modelsync
|
|
2
|
+
|
|
3
|
+
A Python library for schema and model synchronization: keep your database schema in sync with your SQLAlchemy or SQLModel definitions.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Python 3.11+
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
Create a virtual environment and install the package in editable mode:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python3 -m venv .venv
|
|
15
|
+
source .venv/bin/activate # Linux/macOS
|
|
16
|
+
# .venv\Scripts\activate # Windows
|
|
17
|
+
pip install -e ".[dev]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Installing in other projects
|
|
21
|
+
|
|
22
|
+
To use modelsync in another Python application:
|
|
23
|
+
|
|
24
|
+
- **Development (same machine):** From your other project, run `pip install -e /path/to/modelsync` or `uv pip install -e /path/to/modelsync`. Changes in the modelsync repo are reflected immediately.
|
|
25
|
+
- **Built wheel:** From the modelsync repo run `uv build` (requires `uv` and dev deps: `pip install -e ".[dev]"`). This produces a wheel in `dist/` (e.g. `dist/modelsync-0.1.0-py3-none-any.whl`). In your other project: `pip install /path/to/modelsync/dist/modelsync-0.1.0-py3-none-any.whl`.
|
|
26
|
+
- **Private index:** Upload the contents of `dist/` to your index; then `pip install modelsync --index-url https://your-index/simple/`.
|
|
27
|
+
|
|
28
|
+
## Running tests
|
|
29
|
+
|
|
30
|
+
From the project root, with the virtual environment activated:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pytest tests/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run only unit tests or only integration tests:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pytest tests/unit/
|
|
40
|
+
pytest tests/integration/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
See `tests/TESTS_README.md` for how tests are organized.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Use modelsync as a library: define your models (e.g. SQLAlchemy or SQLModel), then compare them to the database. By default, only a plan is produced; apply only when you explicitly opt in.
|
|
48
|
+
|
|
49
|
+
### ModelSync: the three main entry points
|
|
50
|
+
|
|
51
|
+
**1. `ModelSync(...)` — set up the connection**
|
|
52
|
+
|
|
53
|
+
Create a `ModelSync` instance by passing either:
|
|
54
|
+
|
|
55
|
+
- **`credentials={"url": "sqlite:///./mydb.sqlite"}`** — modelsync will open the database, run your call, then close it. No need to manage the connection yourself.
|
|
56
|
+
- **`connection=engine.connect()`** — you provide an open connection; you are responsible for closing it when done.
|
|
57
|
+
|
|
58
|
+
For databases that use schemas (e.g. PostgreSQL), also pass **`target_schema="public"`** (or your schema name). For SQLite you can omit it.
|
|
59
|
+
|
|
60
|
+
**2. `compare(models)` — see what would change (dry run)**
|
|
61
|
+
|
|
62
|
+
Pass one model class or a list of model classes. modelsync compares their combined schema to the live database and returns a **plan** of steps (create table, add column, add constraint, etc.) without executing anything. Use this to inspect changes, log them, or generate a DDL script with `plan.sql()`. Returns a `SyncPlan` or a `SyncError` if something went wrong.
|
|
63
|
+
|
|
64
|
+
**3. `do_sync(models)` — apply the changes**
|
|
65
|
+
|
|
66
|
+
Same comparison as `compare()`, but **runs** the plan against the database. By default all steps run in one transaction: if any step fails, everything is rolled back. Returns the applied `SyncPlan` on success or a `SyncError` on failure. Optional: `commit_per_step=True` to commit after each step (partial progress on failure), or `log_file="path"` to append applied steps to a file.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
Define one or more models and pass them (single class or a list) to `compare()` or `do_sync()`. The example below uses `compare()` to get a plan.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from sqlalchemy import Column, Float, ForeignKey, Integer, String
|
|
74
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
75
|
+
|
|
76
|
+
from modelsync import ModelSync, SyncError
|
|
77
|
+
|
|
78
|
+
class Product(DeclarativeBase):
|
|
79
|
+
__tablename__ = "product"
|
|
80
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
81
|
+
name = Column(String(255), nullable=False)
|
|
82
|
+
price = Column(Float, nullable=False)
|
|
83
|
+
|
|
84
|
+
class Cart(DeclarativeBase):
|
|
85
|
+
__tablename__ = "cart"
|
|
86
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
87
|
+
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
|
|
88
|
+
quantity = Column(Integer, nullable=False)
|
|
89
|
+
|
|
90
|
+
sync = ModelSync(credentials={"url": "sqlite:///./mydb.sqlite"})
|
|
91
|
+
result_plan = sync.compare([Product, Cart])
|
|
92
|
+
|
|
93
|
+
if isinstance(result_plan, SyncError):
|
|
94
|
+
print("Compare failed:", result_plan.messages)
|
|
95
|
+
else:
|
|
96
|
+
if not result_plan.steps:
|
|
97
|
+
print("Your target database is up to date.")
|
|
98
|
+
else:
|
|
99
|
+
for step in result_plan.steps:
|
|
100
|
+
print(step)
|
|
101
|
+
# result_plan.sql() returns the full DDL script for inspection or manual execution
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Using your own connection** — you open the connection and close it yourself:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from sqlalchemy import create_engine
|
|
108
|
+
|
|
109
|
+
engine = create_engine("sqlite:///./mydb.sqlite")
|
|
110
|
+
with engine.connect() as conn:
|
|
111
|
+
sync = ModelSync(connection=conn)
|
|
112
|
+
result_plan = sync.compare([Product, Cart])
|
|
113
|
+
engine.dispose()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Possible Outcomes
|
|
117
|
+
|
|
118
|
+
#### Scenario 1
|
|
119
|
+
If the target database is empty, printing each step might show:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Create table product
|
|
123
|
+
Create table cart
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Scenario 2
|
|
127
|
+
If the database already matches your models, you'll see *Your target database is up to date.* and no steps.
|
|
128
|
+
|
|
129
|
+
#### Scenario 3
|
|
130
|
+
What if `cart` already exists in the database but is missing the `quantity` column? Then you might see:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
Add column quantity to cart
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
To get the full DDL script as a single string, use `result_plan.sql()`.
|
|
137
|
+
|
|
138
|
+
### Applying the plan with do_sync()
|
|
139
|
+
|
|
140
|
+
To compare and apply the plan in one go (run the DDL against the database), use `do_sync()`. It uses the same comparison as `compare()` but executes the steps in a single transaction (all-or-nothing; rollback on failure). Returns the applied `SyncPlan` on success or `SyncError` on failure.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
result_plan = sync.do_sync([Product, Cart])
|
|
144
|
+
|
|
145
|
+
if isinstance(result_plan, SyncError):
|
|
146
|
+
print("Sync failed:", result_plan.messages)
|
|
147
|
+
else:
|
|
148
|
+
print(f"Applied {len(result_plan.steps)} step(s). Schema is now in sync.")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Optional: `do_sync(..., commit_per_step=True)` commits after each step so partial progress is kept if a later step fails. `do_sync(..., log_file="/path/to/sync.log")` appends applied steps as JSON lines to a file.
|
|
152
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "modelsync"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Synchronize database models with actuals — document-driven project."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Brian L. Pond" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"sqlalchemy>=2.0",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/brian-pond/modelsync"
|
|
25
|
+
Repository = "https://github.com/brian-pond/modelsync"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"build",
|
|
30
|
+
"pytest",
|
|
31
|
+
"ruff",
|
|
32
|
+
"sqlmodel",
|
|
33
|
+
"typer>=0.9",
|
|
34
|
+
]
|
|
35
|
+
postgres = [
|
|
36
|
+
"psycopg[binary]>=3",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py311"
|
|
41
|
+
line-length = 100
|
|
42
|
+
src = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "B", "C4", "UP", "ARG", "SIM"]
|
|
46
|
+
ignore = []
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[project.scripts]
|
|
52
|
+
modelsync = "modelsync.cli:main"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
modelsync — schema and model synchronization.
|
|
3
|
+
|
|
4
|
+
Document-driven project; requirements live under docs/requirements/.
|
|
5
|
+
Library API: ModelSync(connection=... | credentials=..., target_schema=...),
|
|
6
|
+
sync.compare(models) -> SyncPlan | SyncError.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("modelsync")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.0"
|
|
15
|
+
|
|
16
|
+
from modelsync.errors import SyncError
|
|
17
|
+
from modelsync.plan.steps import SyncPlan
|
|
18
|
+
from modelsync.sync import ModelSync
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ModelSync",
|
|
22
|
+
"SyncError",
|
|
23
|
+
"SyncPlan",
|
|
24
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapters: third-party models (SQLAlchemy, SQLModel) → internal schema.
|
|
3
|
+
|
|
4
|
+
See docs/technical/02-architecture.md (Core functions, Adapters).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from modelsync.adapters.model_schema import ModelSchema
|
|
8
|
+
from modelsync.adapters.sa_to_neutral import sa_column_to_neutral_type
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ModelSchema",
|
|
12
|
+
"sa_column_to_neutral_type",
|
|
13
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build internal schema from SQLAlchemy or SQLModel model classes.
|
|
3
|
+
|
|
4
|
+
Callers pass a single model or sequence of models; we collect their Table
|
|
5
|
+
definitions and build a ModelSchema (name -> TableDef). See docs/requirements/01-functional.md
|
|
6
|
+
(Model discovery and API, Schema parity scope).
|
|
7
|
+
|
|
8
|
+
**Read-only contract:** Ingestion does not mutate caller models. We only read from
|
|
9
|
+
model.__table__ and its columns/constraints/indexes; we never assign to or modify
|
|
10
|
+
the caller's Table or column objects. ModelSchema stores only internal TableDef
|
|
11
|
+
instances, not references to the original tables.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import Table
|
|
20
|
+
from sqlalchemy.engine import Dialect
|
|
21
|
+
from sqlalchemy.schema import CheckConstraint, ForeignKeyConstraint, UniqueConstraint
|
|
22
|
+
|
|
23
|
+
from modelsync.adapters.sa_to_neutral import sa_column_to_neutral_type
|
|
24
|
+
from modelsync.internal.objects import (
|
|
25
|
+
CheckDef,
|
|
26
|
+
ColumnDef,
|
|
27
|
+
ForeignKeyDef,
|
|
28
|
+
IndexDef,
|
|
29
|
+
PrimaryKeyDef,
|
|
30
|
+
QualifiedName,
|
|
31
|
+
TableDef,
|
|
32
|
+
UniqueDef,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_table_from_model(model: type) -> Table:
|
|
37
|
+
"""Return the SQLAlchemy Table for a declarative or SQLModel class."""
|
|
38
|
+
table = getattr(model, "__table__", None)
|
|
39
|
+
if table is None:
|
|
40
|
+
raise TypeError(f"Model {model!r} has no __table__; not a mapped table class")
|
|
41
|
+
if not isinstance(table, Table):
|
|
42
|
+
raise TypeError(f"Model {model!r}.__table__ is not a Table: {type(table)}")
|
|
43
|
+
return table
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _column_type_str(column: Any, dialect: Dialect | None) -> str:
|
|
47
|
+
"""Return neutral type string for internal schema (dialect=None) or compiled type (dialect set)."""
|
|
48
|
+
if dialect is None:
|
|
49
|
+
return sa_column_to_neutral_type(column)
|
|
50
|
+
return column.type.compile(dialect=dialect)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_expr(column: Any, _dialect: Dialect) -> str | None:
|
|
54
|
+
"""Return server default expression as string, or None."""
|
|
55
|
+
default = getattr(column, "server_default", None) or getattr(column, "default", None)
|
|
56
|
+
if default is None:
|
|
57
|
+
return None
|
|
58
|
+
if hasattr(default, "arg") and default.arg is not None:
|
|
59
|
+
if callable(default.arg):
|
|
60
|
+
return None # Python-side default; no DDL expression
|
|
61
|
+
return str(default.arg)
|
|
62
|
+
if hasattr(default, "text") and default.text is not None:
|
|
63
|
+
return default.text
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extract_table_def(
|
|
68
|
+
table: Table,
|
|
69
|
+
target_schema: str | None,
|
|
70
|
+
dialect: Dialect | None = None,
|
|
71
|
+
) -> TableDef:
|
|
72
|
+
"""Build a TableDef from a SQLAlchemy Table. When dialect is None, use neutral type names."""
|
|
73
|
+
schema = table.schema if table.schema is not None else target_schema
|
|
74
|
+
qualified_name = QualifiedName(schema=schema, name=table.name)
|
|
75
|
+
|
|
76
|
+
columns: list[ColumnDef] = []
|
|
77
|
+
# Only the single integer PK column should have autoincrement=True (SA uses "auto" on others too).
|
|
78
|
+
is_single_pk = (
|
|
79
|
+
table.primary_key
|
|
80
|
+
and len(table.primary_key.columns) == 1
|
|
81
|
+
)
|
|
82
|
+
pk_col_name = (
|
|
83
|
+
list(table.primary_key.columns)[0].name
|
|
84
|
+
if is_single_pk
|
|
85
|
+
else None
|
|
86
|
+
)
|
|
87
|
+
_integer_type_names = ("Integer", "INTEGER", "BigInteger", "BIGINT", "SmallInteger", "SMALLINT")
|
|
88
|
+
for col in table.c:
|
|
89
|
+
default = _default_expr(col, dialect)
|
|
90
|
+
type_str = _column_type_str(col, dialect)
|
|
91
|
+
comment = getattr(col, "comment", None)
|
|
92
|
+
sa_auto = getattr(col, "autoincrement", False)
|
|
93
|
+
if isinstance(sa_auto, str):
|
|
94
|
+
sa_auto = sa_auto == "auto"
|
|
95
|
+
is_pk_col = pk_col_name is not None and col.name == pk_col_name
|
|
96
|
+
is_integer = type(col.type).__name__ in _integer_type_names
|
|
97
|
+
autoincrement = bool(
|
|
98
|
+
is_single_pk and is_pk_col and is_integer and sa_auto
|
|
99
|
+
)
|
|
100
|
+
columns.append(
|
|
101
|
+
ColumnDef(
|
|
102
|
+
name=col.name,
|
|
103
|
+
data_type_name=type_str,
|
|
104
|
+
nullable=col.nullable,
|
|
105
|
+
default=default,
|
|
106
|
+
comment=comment,
|
|
107
|
+
autoincrement=autoincrement,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
primary_key: PrimaryKeyDef | None = None
|
|
112
|
+
if table.primary_key and table.primary_key.columns:
|
|
113
|
+
primary_key = PrimaryKeyDef(column_names=tuple(c.name for c in table.primary_key.columns))
|
|
114
|
+
|
|
115
|
+
unique_constraints: list[UniqueDef] = []
|
|
116
|
+
foreign_keys: list[ForeignKeyDef] = []
|
|
117
|
+
check_constraints: list[CheckDef] = []
|
|
118
|
+
|
|
119
|
+
for constraint in table.constraints:
|
|
120
|
+
match constraint:
|
|
121
|
+
case UniqueConstraint() if constraint is not table.primary_key:
|
|
122
|
+
unique_constraints.append(
|
|
123
|
+
UniqueDef(
|
|
124
|
+
name=constraint.name,
|
|
125
|
+
column_names=tuple(c.name for c in constraint.columns),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
case CheckConstraint():
|
|
129
|
+
expression = str(constraint.sqltext)
|
|
130
|
+
check_constraints.append(CheckDef(name=constraint.name, expression=expression))
|
|
131
|
+
case ForeignKeyConstraint():
|
|
132
|
+
ref_col = next(iter(constraint.elements)).column
|
|
133
|
+
ref_table = ref_col.table
|
|
134
|
+
ref_schema = ref_table.schema if ref_table.schema is not None else target_schema
|
|
135
|
+
ref_name = QualifiedName(schema=ref_schema, name=ref_table.name)
|
|
136
|
+
foreign_keys.append(
|
|
137
|
+
ForeignKeyDef(
|
|
138
|
+
name=constraint.name,
|
|
139
|
+
column_names=tuple(c.name for c in constraint.columns),
|
|
140
|
+
ref_table=ref_name,
|
|
141
|
+
ref_column_names=tuple(el.column.name for el in constraint.elements),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
indexes: list[IndexDef] = []
|
|
146
|
+
for idx in table.indexes:
|
|
147
|
+
indexes.append(
|
|
148
|
+
IndexDef(
|
|
149
|
+
name=idx.name or f"ix_{table.name}_{'_'.join(c.name for c in idx.columns)}",
|
|
150
|
+
column_names=tuple(c.name for c in idx.columns),
|
|
151
|
+
unique=idx.unique or False,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
comment = getattr(table, "comment", None)
|
|
156
|
+
|
|
157
|
+
return TableDef(
|
|
158
|
+
name=qualified_name,
|
|
159
|
+
columns=tuple(columns),
|
|
160
|
+
primary_key=primary_key,
|
|
161
|
+
unique_constraints=tuple(unique_constraints),
|
|
162
|
+
foreign_keys=tuple(foreign_keys),
|
|
163
|
+
check_constraints=tuple(check_constraints),
|
|
164
|
+
indexes=tuple(indexes),
|
|
165
|
+
comment=comment,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _SchemaNormalizer(Protocol):
|
|
170
|
+
"""Protocol for normalizing a TableDef so it compares equal across backends."""
|
|
171
|
+
|
|
172
|
+
def normalize_reflected_table(self, table_def: TableDef) -> TableDef: ...
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ModelSchema:
|
|
176
|
+
"""
|
|
177
|
+
Internal schema derived from code models (SQLAlchemy/SQLModel).
|
|
178
|
+
|
|
179
|
+
Tables are keyed by QualifiedName. Built by from_models().
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._tables: dict[QualifiedName, TableDef] = {}
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def tables(self) -> dict[QualifiedName, TableDef]:
|
|
187
|
+
"""Tables keyed by qualified name."""
|
|
188
|
+
return self._tables
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_models(
|
|
192
|
+
cls,
|
|
193
|
+
models: type | Sequence[type],
|
|
194
|
+
target_schema: str | None = None,
|
|
195
|
+
*,
|
|
196
|
+
schema_normalizer: _SchemaNormalizer | None = None,
|
|
197
|
+
) -> ModelSchema:
|
|
198
|
+
"""
|
|
199
|
+
Build ModelSchema from one or more model classes.
|
|
200
|
+
|
|
201
|
+
Each model must have __table__ (SQLAlchemy Table). Column types are
|
|
202
|
+
mapped to neutral type names (no target database). target_schema is
|
|
203
|
+
used when table.schema is None (e.g. PostgreSQL default schema).
|
|
204
|
+
If schema_normalizer is provided (e.g. modelsync Dialect), its
|
|
205
|
+
normalize_reflected_table is applied so model-side internal schema compares equal to database-side internal schema.
|
|
206
|
+
"""
|
|
207
|
+
if isinstance(models, type):
|
|
208
|
+
model_seq: Sequence[type] = (models,)
|
|
209
|
+
else:
|
|
210
|
+
model_seq = models
|
|
211
|
+
instance = cls()
|
|
212
|
+
for model in model_seq:
|
|
213
|
+
table = _get_table_from_model(model)
|
|
214
|
+
table_def = _extract_table_def(table, target_schema, dialect=None)
|
|
215
|
+
if schema_normalizer is not None:
|
|
216
|
+
table_def = schema_normalizer.normalize_reflected_table(table_def)
|
|
217
|
+
instance._tables[table_def.name] = table_def
|
|
218
|
+
return instance
|