seedgraph 0.1.1__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,17 @@
1
+ # session notes (local only)
2
+ .notes-session.md
3
+
4
+ # planning artifacts (local only)
5
+ ai/
6
+
7
+ # ephemeral viz pages
8
+ .tmp/
9
+
10
+ # python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *.egg-info/
14
+ .venv/
15
+ dist/
16
+ .pytest_cache/
17
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rachid Jeffali
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.
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.5
2
+ Name: seedgraph
3
+ Version: 0.1.1
4
+ Summary: Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, real PKs.
5
+ Project-URL: Homepage, https://github.com/jrachid/seedgraph
6
+ Project-URL: Repository, https://github.com/jrachid/seedgraph
7
+ Project-URL: Issues, https://github.com/jrachid/seedgraph/issues
8
+ Author: Rachid Jeffali
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: fixtures,foreign-keys,orm,seed,sqlalchemy,testing
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: faker>=30
23
+ Requires-Dist: sqlalchemy>=2.0
24
+ Provides-Extra: async
25
+ Requires-Dist: aiosqlite>=0.17; extra == 'async'
26
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'async'
27
+ Provides-Extra: dev
28
+ Requires-Dist: aiosqlite>=0.17; extra == 'dev'
29
+ Requires-Dist: asyncpg>=0.29; extra == 'dev'
30
+ Requires-Dist: greenlet>=3.0; extra == 'dev'
31
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.6; extra == 'dev'
35
+ Requires-Dist: testcontainers[postgres]>=4.0; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # seedgraph
39
+
40
+ > Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, verified links.
41
+
42
+ **seedgraph fills your test database with a coherent graph of objects, in a single call.** You declare what you want — "3 users, each with 2 posts, each post with 3 comments" — and the library builds the objects, links them, writes them to your session and then verifies every foreign key against the row it points at. Values are realistic and reproducible ("Jose Bishop", not "user-0"), generated by Faker with a fixed seed, valid for each column's type, and unique where the schema says so — even against rows already in the database. You can pin any column, replace how any column is generated, and attach the new graph to rows you already have. It plugs into pytest with no configuration.
43
+
44
+ **Status: pre-alpha.** Every guarantee below is backed by a named test, on SQLite and on PostgreSQL.
45
+
46
+ ---
47
+
48
+ ## Why
49
+
50
+ Every Python team that seeds a relational test database eventually hand-rolls the same plumbing: generate rows, stage commits so primary keys exist, chase those keys into FK columns, repeat for every relationship, and hope the graph stays consistent.
51
+
52
+ After empirically testing the landscape (SQLAlchemy 2.x era, August 2026), none of the existing options does it:
53
+
54
+ | Tool | What you get on a `User ← Post ← Comment` schema |
55
+ |------|---------------------------------------------------|
56
+ | `polyfactory` | Builds related objects, **but every FK column is a random int pointing at nothing**: `post.author_id != post.author.id`. Silent data corruption — tests pass on garbage unless you enable FK enforcement (most don't). |
57
+ | `faker-sqlalchemy` (unmaintained since 2022, pinned to SQLAlchemy 1.x) | `RecursionError` on standard `backref` relationships, on self-referential FKs, and its `overrides` API silently drops FK values. |
58
+ | `sqlalchemyseed` | Seeds data *you already have* (JSON/YAML), doesn't generate. |
59
+ | `sqlseed`, `sowdb` | Solid fillers, but schema-level and flat: "N rows per table". They work from raw SQL schemas, not your models, and can't express a graph shape like *"3 users → 2 posts each → 5 comments per post"*. |
60
+
61
+ The one-line failure seedgraph fixes:
62
+
63
+ ```python
64
+ p = PostFactory.build()
65
+ p.author_id == p.author.id # False. Every FK in the graph is disconnected.
66
+ ```
67
+
68
+ ### "But other libraries do this too, don't they?"
69
+
70
+ They create linked objects. Three differences survive a closer look:
71
+
72
+ **1. A verified exit contract, not just object creation.** factory_boy, pytest-factoryboy or mixer build the graph and stop there. `seed()` flushes the graph, lets the database assign the keys, then walks every link and raises `IncoherentGraphError` if a foreign key disagrees with the row it points at.
73
+
74
+ **2. Coexistence with a populated database.** The database assigns the keys, so seeding on top of existing rows never collides on ids, never desynchronises a PostgreSQL sequence, and stays safe when two sessions seed the same tables at once. Unique columns are checked against the rows already there before anything is written.
75
+
76
+ **3. Determinism wired into pytest.** A new session replays the same values from the same seed; consecutive calls on one session continue the sequence instead of repeating it — inside a two-line fixture.
77
+
78
+ ## Quick start
79
+
80
+ Declare a **shape** from a root model; each key walks a one-to-many or many-to-many relationship, by relationship name or by target class name:
81
+
82
+ ```python
83
+ from seedgraph import seed
84
+
85
+ graph = seed(session, User, post=2, post__comment=3) # 3 users by default
86
+
87
+ assert len(graph.users) == 3
88
+ assert len(graph.posts) == 6
89
+ post = graph.users[0].posts[0]
90
+ assert post.author_id == graph.users[0].id # real key, assigned by the database
91
+ assert graph.labels == [] # any table of the model, empty if not seeded
92
+ ```
93
+
94
+ `seed()` returns once the graph is flushed and verified; commit or roll back as your test needs. `seed_async(async_session, ...)` is its twin for an `AsyncSession`.
95
+
96
+ ### Existing and missing parents
97
+
98
+ ```python
99
+ alice = session.get(User, 1)
100
+ graph = seed(session, Post, parents=[alice]) # every post's author is alice; alice is not in graph.users
101
+
102
+ graph = seed(session, Comment) # one Post and one User are generated, shared by all comments
103
+ ```
104
+
105
+ A link first takes the nearest ancestor of its type in the shape, then the object of that type passed in `parents` (optional links included). A required link still empty gets **one** generated parent per type, shared by every object that needs it. Several objects of one type are accepted in `parents`; a link towards a single parent refuses to choose between them with `AmbiguousParentError`.
106
+
107
+ ### Many-to-many
108
+
109
+ ```python
110
+ graph = seed(session, Article, article=5, tags=3) # 15 new tags, 3 per article
111
+ graph = seed(session, Article, article=5, parents=[python, sql]) # every article tagged with both existing tags
112
+ ```
113
+
114
+ A count keeps its one-to-many meaning: new objects for each parent. Objects passed in `parents` join every many-to-many collection of their type, next to the ones the shape builds. SQLAlchemy writes the association rows itself.
115
+
116
+ ### Pinning and generating values
117
+
118
+ ```python
119
+ graph = seed(
120
+ session,
121
+ User,
122
+ post=2,
123
+ generators={User: {"name": lambda ctx: ctx.fake.first_name()}}, # replace how a column is generated
124
+ overrides={Post: {"title": "Imposed", "subtitle": None}}, # pin a value, None included
125
+ )
126
+ ```
127
+
128
+ `ctx.fake` is the session's seeded Faker; `ctx.column` is the column name. An override can also be a callable taking the same context.
129
+
130
+ ### pytest
131
+
132
+ Installing seedgraph registers four fixtures, prefixed so they never shadow your own `session` or `graph`:
133
+
134
+ ```python
135
+ def test_feed(seedgraph_graph):
136
+ graph = seedgraph_graph(User, post=2) # fresh in-memory SQLite, FK enforced, tables created on demand
137
+ assert len(graph.posts) == 6
138
+
139
+ async def test_feed_async(seedgraph_agraph):
140
+ graph = await seedgraph_agraph(User, post=2)
141
+ ```
142
+
143
+ `seedgraph_session` and `seedgraph_asession` expose the sessions behind them. To seed your own database, call `seed()` on your own session.
144
+
145
+ ## Guarantees and the tests that prove them
146
+
147
+ | Guarantee | Test |
148
+ |---|---|
149
+ | Every link of the returned graph is verified after flush | `test_verification.py::test_seed_returns_a_graph_already_written_with_real_keys`, `::test_verify_graph_names_the_link_whose_foreign_key_disagrees` |
150
+ | Seeding on top of existing rows keeps PostgreSQL sequences intact | `test_postgres.py::test_the_application_still_inserts_after_a_seed_on_top_of_its_rows` |
151
+ | Two sessions seeding the same tables at once do not collide | `test_postgres.py::test_two_sessions_seeding_the_same_tables_at_once_do_not_collide` |
152
+ | Unique columns skip values already in the database | `test_unique.py::test_a_new_session_on_a_populated_database_skips_the_values_already_taken`, `::test_postgres_rows_from_an_earlier_run_do_not_block_a_new_seed` |
153
+ | Same seed, same values; consecutive calls do not repeat | `test_generators.py::test_a_new_session_replays_the_same_values`, `::test_two_seeds_in_one_session_continue_the_same_faker_sequence` |
154
+ | Generated values fit the column type (enum, length, precision, arrays) | `test_types.py` |
155
+ | Many-to-many shapes build new objects per parent; existing objects are shared | `test_many_to_many.py::test_a_many_to_many_count_builds_new_objects_for_each_parent`, `::test_existing_objects_passed_as_parents_are_shared_by_every_generated_object` |
156
+ | Natural and composite primary keys are generated and never collide | `test_verification.py::test_a_natural_text_key_is_generated`, `::test_natural_keys_skip_the_ones_already_in_the_database`, `::test_a_composite_integer_key_and_its_composite_foreign_key_are_generated` |
157
+ | Multi-column unique constraints hold | `test_unique.py::test_many_rows_under_one_parent_keep_a_multi_column_constraint`, `::test_a_second_session_keeps_a_multi_column_constraint` |
158
+ | Existing rows serve as parents; missing required parents are generated once | `test_parents.py::test_a_parent_already_in_the_database_is_linked_and_left_out_of_the_graph`, `::test_a_child_seeded_alone_gets_one_generated_parent_shared_by_all` |
159
+ | Plugin fixtures live beside a project's own `session` and `graph` | `test_fixtures.py::test_the_prefixed_fixtures_live_beside_a_project_own_session_and_graph` |
160
+
161
+ ## Limits
162
+
163
+ - **`seed()` flushes the session.** The keys come from the database; the objects are no longer pending when it returns.
164
+ - **A shape key towards a parent is refused**, as parents are linked or generated on their own; pass existing ones in `parents`. View-only relationships are refused too, since nothing would be written.
165
+ - **Required columns of uncovered types** (JSON, custom `TypeDecorator`, arrays of those) raise `UnsupportedPlaceholderError`; declare a generator for them. Nullable ones are left empty.
166
+ - **A multi-column unique constraint whose generated columns are only booleans or enums** is left to the database. For the others, one generated column is kept unique on its own, which is stricter than the constraint.
167
+ - **An association class whose primary key combines its two foreign keys** holds one row per parent pair: the generated parent is shared, so two rows under the same parent collide. Seed one per parent, or use a many-to-many relationship.
168
+ - **A loop of required links between tables**, or a required link to its own table, cannot be generated; pass one side in `parents`.
169
+ - **Determinism holds for a given Faker version.** Faker may change its data between releases.
170
+
171
+ ## Design principles
172
+
173
+ 1. **Model-first, not schema-first.** Works from your SQLAlchemy ORM models and relationships.
174
+ 2. **Referential consistency is verified, not hoped for.** The database assigns the keys, seedgraph checks every link afterwards.
175
+ 3. **Shared parents are the point.** Realistic data shares parents (one author, many posts). One object per FK is not a graph.
176
+ 4. **Deterministic.** A new session with the same calls produces the same graph.
177
+ 5. **Self-references and mutually referencing tables are normal.** `Category.parent` and tables pointing at each other are supported; only unsatisfiable loops of required links are refused.
178
+ 6. **Stop generating at the boundary.** Existing rows are usable as parents; only missing parents get generated.
179
+
180
+ ## Roadmap
181
+
182
+ - [x] Shape API (`relation=n`, nesting, shared parents)
183
+ - [x] Custom field generators (Faker under the hood)
184
+ - [x] Overriding specific attributes on generated objects
185
+ - [x] pytest fixture helpers
186
+ - [x] Self-referential and cyclic FKs
187
+ - [x] Async sessions support
188
+ - [x] Database-assigned keys and post-flush verification, PostgreSQL in the test suite
189
+ - [x] Type-valid values, uniqueness against existing rows, existing and generated parents
190
+ - [x] Many-to-many shapes, generated natural keys, multi-column uniqueness, arrays
191
+ - [ ] Publication on PyPI
192
+
193
+ ## Installation
194
+
195
+ ```bash
196
+ pip install seedgraph
197
+ pip install "seedgraph[async]" # for seed_async and the async pytest fixtures
198
+ ```
199
+
200
+ Requires Python 3.11+, SQLAlchemy 2.x and Faker 30+. The async extra adds greenlet (through `sqlalchemy[asyncio]`) and aiosqlite. The PostgreSQL tests of the suite need Docker and are skipped without it.
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,167 @@
1
+ # seedgraph
2
+
3
+ > Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, verified links.
4
+
5
+ **seedgraph fills your test database with a coherent graph of objects, in a single call.** You declare what you want — "3 users, each with 2 posts, each post with 3 comments" — and the library builds the objects, links them, writes them to your session and then verifies every foreign key against the row it points at. Values are realistic and reproducible ("Jose Bishop", not "user-0"), generated by Faker with a fixed seed, valid for each column's type, and unique where the schema says so — even against rows already in the database. You can pin any column, replace how any column is generated, and attach the new graph to rows you already have. It plugs into pytest with no configuration.
6
+
7
+ **Status: pre-alpha.** Every guarantee below is backed by a named test, on SQLite and on PostgreSQL.
8
+
9
+ ---
10
+
11
+ ## Why
12
+
13
+ Every Python team that seeds a relational test database eventually hand-rolls the same plumbing: generate rows, stage commits so primary keys exist, chase those keys into FK columns, repeat for every relationship, and hope the graph stays consistent.
14
+
15
+ After empirically testing the landscape (SQLAlchemy 2.x era, August 2026), none of the existing options does it:
16
+
17
+ | Tool | What you get on a `User ← Post ← Comment` schema |
18
+ |------|---------------------------------------------------|
19
+ | `polyfactory` | Builds related objects, **but every FK column is a random int pointing at nothing**: `post.author_id != post.author.id`. Silent data corruption — tests pass on garbage unless you enable FK enforcement (most don't). |
20
+ | `faker-sqlalchemy` (unmaintained since 2022, pinned to SQLAlchemy 1.x) | `RecursionError` on standard `backref` relationships, on self-referential FKs, and its `overrides` API silently drops FK values. |
21
+ | `sqlalchemyseed` | Seeds data *you already have* (JSON/YAML), doesn't generate. |
22
+ | `sqlseed`, `sowdb` | Solid fillers, but schema-level and flat: "N rows per table". They work from raw SQL schemas, not your models, and can't express a graph shape like *"3 users → 2 posts each → 5 comments per post"*. |
23
+
24
+ The one-line failure seedgraph fixes:
25
+
26
+ ```python
27
+ p = PostFactory.build()
28
+ p.author_id == p.author.id # False. Every FK in the graph is disconnected.
29
+ ```
30
+
31
+ ### "But other libraries do this too, don't they?"
32
+
33
+ They create linked objects. Three differences survive a closer look:
34
+
35
+ **1. A verified exit contract, not just object creation.** factory_boy, pytest-factoryboy or mixer build the graph and stop there. `seed()` flushes the graph, lets the database assign the keys, then walks every link and raises `IncoherentGraphError` if a foreign key disagrees with the row it points at.
36
+
37
+ **2. Coexistence with a populated database.** The database assigns the keys, so seeding on top of existing rows never collides on ids, never desynchronises a PostgreSQL sequence, and stays safe when two sessions seed the same tables at once. Unique columns are checked against the rows already there before anything is written.
38
+
39
+ **3. Determinism wired into pytest.** A new session replays the same values from the same seed; consecutive calls on one session continue the sequence instead of repeating it — inside a two-line fixture.
40
+
41
+ ## Quick start
42
+
43
+ Declare a **shape** from a root model; each key walks a one-to-many or many-to-many relationship, by relationship name or by target class name:
44
+
45
+ ```python
46
+ from seedgraph import seed
47
+
48
+ graph = seed(session, User, post=2, post__comment=3) # 3 users by default
49
+
50
+ assert len(graph.users) == 3
51
+ assert len(graph.posts) == 6
52
+ post = graph.users[0].posts[0]
53
+ assert post.author_id == graph.users[0].id # real key, assigned by the database
54
+ assert graph.labels == [] # any table of the model, empty if not seeded
55
+ ```
56
+
57
+ `seed()` returns once the graph is flushed and verified; commit or roll back as your test needs. `seed_async(async_session, ...)` is its twin for an `AsyncSession`.
58
+
59
+ ### Existing and missing parents
60
+
61
+ ```python
62
+ alice = session.get(User, 1)
63
+ graph = seed(session, Post, parents=[alice]) # every post's author is alice; alice is not in graph.users
64
+
65
+ graph = seed(session, Comment) # one Post and one User are generated, shared by all comments
66
+ ```
67
+
68
+ A link first takes the nearest ancestor of its type in the shape, then the object of that type passed in `parents` (optional links included). A required link still empty gets **one** generated parent per type, shared by every object that needs it. Several objects of one type are accepted in `parents`; a link towards a single parent refuses to choose between them with `AmbiguousParentError`.
69
+
70
+ ### Many-to-many
71
+
72
+ ```python
73
+ graph = seed(session, Article, article=5, tags=3) # 15 new tags, 3 per article
74
+ graph = seed(session, Article, article=5, parents=[python, sql]) # every article tagged with both existing tags
75
+ ```
76
+
77
+ A count keeps its one-to-many meaning: new objects for each parent. Objects passed in `parents` join every many-to-many collection of their type, next to the ones the shape builds. SQLAlchemy writes the association rows itself.
78
+
79
+ ### Pinning and generating values
80
+
81
+ ```python
82
+ graph = seed(
83
+ session,
84
+ User,
85
+ post=2,
86
+ generators={User: {"name": lambda ctx: ctx.fake.first_name()}}, # replace how a column is generated
87
+ overrides={Post: {"title": "Imposed", "subtitle": None}}, # pin a value, None included
88
+ )
89
+ ```
90
+
91
+ `ctx.fake` is the session's seeded Faker; `ctx.column` is the column name. An override can also be a callable taking the same context.
92
+
93
+ ### pytest
94
+
95
+ Installing seedgraph registers four fixtures, prefixed so they never shadow your own `session` or `graph`:
96
+
97
+ ```python
98
+ def test_feed(seedgraph_graph):
99
+ graph = seedgraph_graph(User, post=2) # fresh in-memory SQLite, FK enforced, tables created on demand
100
+ assert len(graph.posts) == 6
101
+
102
+ async def test_feed_async(seedgraph_agraph):
103
+ graph = await seedgraph_agraph(User, post=2)
104
+ ```
105
+
106
+ `seedgraph_session` and `seedgraph_asession` expose the sessions behind them. To seed your own database, call `seed()` on your own session.
107
+
108
+ ## Guarantees and the tests that prove them
109
+
110
+ | Guarantee | Test |
111
+ |---|---|
112
+ | Every link of the returned graph is verified after flush | `test_verification.py::test_seed_returns_a_graph_already_written_with_real_keys`, `::test_verify_graph_names_the_link_whose_foreign_key_disagrees` |
113
+ | Seeding on top of existing rows keeps PostgreSQL sequences intact | `test_postgres.py::test_the_application_still_inserts_after_a_seed_on_top_of_its_rows` |
114
+ | Two sessions seeding the same tables at once do not collide | `test_postgres.py::test_two_sessions_seeding_the_same_tables_at_once_do_not_collide` |
115
+ | Unique columns skip values already in the database | `test_unique.py::test_a_new_session_on_a_populated_database_skips_the_values_already_taken`, `::test_postgres_rows_from_an_earlier_run_do_not_block_a_new_seed` |
116
+ | Same seed, same values; consecutive calls do not repeat | `test_generators.py::test_a_new_session_replays_the_same_values`, `::test_two_seeds_in_one_session_continue_the_same_faker_sequence` |
117
+ | Generated values fit the column type (enum, length, precision, arrays) | `test_types.py` |
118
+ | Many-to-many shapes build new objects per parent; existing objects are shared | `test_many_to_many.py::test_a_many_to_many_count_builds_new_objects_for_each_parent`, `::test_existing_objects_passed_as_parents_are_shared_by_every_generated_object` |
119
+ | Natural and composite primary keys are generated and never collide | `test_verification.py::test_a_natural_text_key_is_generated`, `::test_natural_keys_skip_the_ones_already_in_the_database`, `::test_a_composite_integer_key_and_its_composite_foreign_key_are_generated` |
120
+ | Multi-column unique constraints hold | `test_unique.py::test_many_rows_under_one_parent_keep_a_multi_column_constraint`, `::test_a_second_session_keeps_a_multi_column_constraint` |
121
+ | Existing rows serve as parents; missing required parents are generated once | `test_parents.py::test_a_parent_already_in_the_database_is_linked_and_left_out_of_the_graph`, `::test_a_child_seeded_alone_gets_one_generated_parent_shared_by_all` |
122
+ | Plugin fixtures live beside a project's own `session` and `graph` | `test_fixtures.py::test_the_prefixed_fixtures_live_beside_a_project_own_session_and_graph` |
123
+
124
+ ## Limits
125
+
126
+ - **`seed()` flushes the session.** The keys come from the database; the objects are no longer pending when it returns.
127
+ - **A shape key towards a parent is refused**, as parents are linked or generated on their own; pass existing ones in `parents`. View-only relationships are refused too, since nothing would be written.
128
+ - **Required columns of uncovered types** (JSON, custom `TypeDecorator`, arrays of those) raise `UnsupportedPlaceholderError`; declare a generator for them. Nullable ones are left empty.
129
+ - **A multi-column unique constraint whose generated columns are only booleans or enums** is left to the database. For the others, one generated column is kept unique on its own, which is stricter than the constraint.
130
+ - **An association class whose primary key combines its two foreign keys** holds one row per parent pair: the generated parent is shared, so two rows under the same parent collide. Seed one per parent, or use a many-to-many relationship.
131
+ - **A loop of required links between tables**, or a required link to its own table, cannot be generated; pass one side in `parents`.
132
+ - **Determinism holds for a given Faker version.** Faker may change its data between releases.
133
+
134
+ ## Design principles
135
+
136
+ 1. **Model-first, not schema-first.** Works from your SQLAlchemy ORM models and relationships.
137
+ 2. **Referential consistency is verified, not hoped for.** The database assigns the keys, seedgraph checks every link afterwards.
138
+ 3. **Shared parents are the point.** Realistic data shares parents (one author, many posts). One object per FK is not a graph.
139
+ 4. **Deterministic.** A new session with the same calls produces the same graph.
140
+ 5. **Self-references and mutually referencing tables are normal.** `Category.parent` and tables pointing at each other are supported; only unsatisfiable loops of required links are refused.
141
+ 6. **Stop generating at the boundary.** Existing rows are usable as parents; only missing parents get generated.
142
+
143
+ ## Roadmap
144
+
145
+ - [x] Shape API (`relation=n`, nesting, shared parents)
146
+ - [x] Custom field generators (Faker under the hood)
147
+ - [x] Overriding specific attributes on generated objects
148
+ - [x] pytest fixture helpers
149
+ - [x] Self-referential and cyclic FKs
150
+ - [x] Async sessions support
151
+ - [x] Database-assigned keys and post-flush verification, PostgreSQL in the test suite
152
+ - [x] Type-valid values, uniqueness against existing rows, existing and generated parents
153
+ - [x] Many-to-many shapes, generated natural keys, multi-column uniqueness, arrays
154
+ - [ ] Publication on PyPI
155
+
156
+ ## Installation
157
+
158
+ ```bash
159
+ pip install seedgraph
160
+ pip install "seedgraph[async]" # for seed_async and the async pytest fixtures
161
+ ```
162
+
163
+ Requires Python 3.11+, SQLAlchemy 2.x and Faker 30+. The async extra adds greenlet (through `sqlalchemy[asyncio]`) and aiosqlite. The PostgreSQL tests of the suite need Docker and are skipped without it.
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1 @@
1
+ pytest_plugins = ["pytester"]
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "seedgraph"
7
+ version = "0.1.1"
8
+ description = "Seed your SQLAlchemy models as a referentially-consistent graph — one call, shared parents, real PKs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Rachid Jeffali" }]
13
+ keywords = ["sqlalchemy", "seed", "fixtures", "testing", "orm", "foreign-keys"]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Framework :: Pytest",
21
+ "Topic :: Database",
22
+ "Topic :: Software Development :: Testing",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["sqlalchemy>=2.0", "faker>=30"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/jrachid/seedgraph"
29
+ Repository = "https://github.com/jrachid/seedgraph"
30
+ Issues = "https://github.com/jrachid/seedgraph/issues"
31
+
32
+ [project.optional-dependencies]
33
+ async = ["sqlalchemy[asyncio]>=2.0", "aiosqlite>=0.17"]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "ruff>=0.6",
37
+ "aiosqlite>=0.17",
38
+ "pytest-asyncio>=0.23",
39
+ "greenlet>=3.0",
40
+ "testcontainers[postgres]>=4.0",
41
+ "psycopg[binary]>=3.1",
42
+ "asyncpg>=0.29",
43
+ ]
44
+
45
+ [project.entry-points.pytest11]
46
+ seedgraph = "seedgraph.pytest_plugin"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/seedgraph"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ asyncio_mode = "auto"
54
+ markers = ["postgres: needs Docker to start a disposable PostgreSQL"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ src = ["src", "tests"]
59
+
60
+ [tool.ruff.lint]
61
+ extend-select = ["ANN"]
62
+ ignore = ["ANN401"]
63
+
64
+ [tool.ruff.lint.per-file-ignores]
65
+ "tests/**" = ["ANN"]
@@ -0,0 +1,118 @@
1
+ """seedgraph — referentially-consistent graph seeding for SQLAlchemy models.
2
+
3
+ Declare a shape, get a coherent object graph: written to the session, every FK
4
+ column verified against the key of the row it points at.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from sqlalchemy.orm import DeclarativeBase, Session
13
+
14
+ from seedgraph.boundary import (
15
+ UnattachedParentError,
16
+ check_parents_attached,
17
+ taken_values,
18
+ taken_values_async,
19
+ )
20
+ from seedgraph.exceptions import SeedgraphError
21
+ from seedgraph.generators import (
22
+ FieldGenerator,
23
+ GeneratorMap,
24
+ OverrideMap,
25
+ UniqueValueExhaustedError,
26
+ UnknownGeneratorColumnError,
27
+ UnknownOverrideColumnError,
28
+ UnsupportedPlaceholderError,
29
+ generation_state,
30
+ )
31
+ from seedgraph.graph import Graph
32
+ from seedgraph.shape import (
33
+ AmbiguousParentError,
34
+ AmbiguousShapeKeyError,
35
+ InvalidShapeCountError,
36
+ MissingRequiredParentError,
37
+ UnknownShapeKeyError,
38
+ UnsupportedShapeDirectionError,
39
+ build_graph,
40
+ )
41
+ from seedgraph.uniqueness import UniqueRepair
42
+ from seedgraph.verification import IncoherentGraphError, verify_graph
43
+
44
+ if TYPE_CHECKING:
45
+ from sqlalchemy.ext.asyncio import AsyncSession
46
+
47
+ __version__ = "0.1.1"
48
+
49
+ __all__ = [
50
+ "AmbiguousParentError",
51
+ "AmbiguousShapeKeyError",
52
+ "Graph",
53
+ "IncoherentGraphError",
54
+ "InvalidShapeCountError",
55
+ "MissingRequiredParentError",
56
+ "SeedgraphError",
57
+ "UnattachedParentError",
58
+ "UniqueValueExhaustedError",
59
+ "UnknownGeneratorColumnError",
60
+ "UnknownOverrideColumnError",
61
+ "UnknownShapeKeyError",
62
+ "UnsupportedPlaceholderError",
63
+ "UnsupportedShapeDirectionError",
64
+ "__version__",
65
+ "seed",
66
+ "seed_async",
67
+ ]
68
+
69
+
70
+ def seed(
71
+ session: Session,
72
+ model: type[DeclarativeBase],
73
+ /,
74
+ generators: GeneratorMap | None = None,
75
+ overrides: OverrideMap | None = None,
76
+ parents: Sequence[Any] = (),
77
+ **shape: int,
78
+ ) -> Graph:
79
+ """Seed a coherent object graph from the declared shape and return it.
80
+
81
+ The graph is flushed, the database assigns its keys, and every FK column is
82
+ verified against the row it points at. ``generators`` replaces how a column
83
+ generates, ``overrides`` pins a value; both are keyed {Model: {"column": ...}}.
84
+ ``parents`` are objects of the session that links of their type point at.
85
+ Raises a ``SeedgraphError`` subclass on any bad declaration or broken link.
86
+ """
87
+ check_parents_attached(session, parents)
88
+ state = generation_state(session.info)
89
+ objects = build_graph(model, shape, generators=generators, overrides=overrides, state=state, parents=parents)
90
+ repair = UniqueRepair(objects, FieldGenerator(generators, overrides, state))
91
+ while queries := repair.queries():
92
+ repair.reject({column: taken_values(session, column, values) for column, values in queries})
93
+ session.add_all(objects)
94
+ session.flush()
95
+ verify_graph(objects)
96
+ return Graph(objects, model.metadata)
97
+
98
+
99
+ async def seed_async(
100
+ session: AsyncSession,
101
+ model: type[DeclarativeBase],
102
+ /,
103
+ generators: GeneratorMap | None = None,
104
+ overrides: OverrideMap | None = None,
105
+ parents: Sequence[Any] = (),
106
+ **shape: int,
107
+ ) -> Graph:
108
+ """Twin of ``seed`` on an AsyncSession: same contract, the flush is awaited."""
109
+ check_parents_attached(session.sync_session, parents)
110
+ state = generation_state(session.sync_session.info)
111
+ objects = build_graph(model, shape, generators=generators, overrides=overrides, state=state, parents=parents)
112
+ repair = UniqueRepair(objects, FieldGenerator(generators, overrides, state))
113
+ while queries := repair.queries():
114
+ repair.reject({column: await taken_values_async(session, column, values) for column, values in queries})
115
+ session.add_all(objects)
116
+ await session.flush()
117
+ verify_graph(objects)
118
+ return Graph(objects, model.metadata)
@@ -0,0 +1,47 @@
1
+ """Read what already exists at the boundary: provided parents, and the values unique columns already hold."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from sqlalchemy import Column, select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from seedgraph.exceptions import SeedgraphError
12
+
13
+ if TYPE_CHECKING:
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ __all__ = ["UnattachedParentError", "check_parents_attached", "taken_values", "taken_values_async"]
17
+
18
+ CHUNK = 500
19
+
20
+
21
+ class UnattachedParentError(SeedgraphError):
22
+ """A provided parent is neither pending nor persistent in the session seeding the graph."""
23
+
24
+
25
+ def check_parents_attached(session: Session, parents: Sequence[Any]) -> None:
26
+ """Refuse any provided parent that the session does not hold, since flushing would insert it silently."""
27
+ for parent in parents:
28
+ if parent not in session:
29
+ raise UnattachedParentError(
30
+ f"parent {type(parent).__name__} is not in the session — add it, or load it, before seeding"
31
+ )
32
+
33
+
34
+ def taken_values(session: Session, column: Column[Any], candidates: Sequence[Any]) -> set[Any]:
35
+ """Return the candidates the column already holds in the database."""
36
+ taken = set()
37
+ for start in range(0, len(candidates), CHUNK):
38
+ taken.update(session.scalars(select(column).where(column.in_(candidates[start : start + CHUNK]))))
39
+ return taken
40
+
41
+
42
+ async def taken_values_async(session: AsyncSession, column: Column[Any], candidates: Sequence[Any]) -> set[Any]:
43
+ """Twin of ``taken_values`` on an AsyncSession."""
44
+ taken = set()
45
+ for start in range(0, len(candidates), CHUNK):
46
+ taken.update(await session.scalars(select(column).where(column.in_(candidates[start : start + CHUNK]))))
47
+ return taken
@@ -0,0 +1,14 @@
1
+ """seedgraph exceptions."""
2
+
3
+ __all__ = ["SeedgraphError"]
4
+
5
+
6
+ class SeedgraphError(Exception):
7
+ """Base class for every error seedgraph raises.
8
+
9
+ Constructed with one message string naming the exact shape key, column or
10
+ model at fault.
11
+ """
12
+
13
+ def __init__(self, message: str) -> None:
14
+ super().__init__(message)