fraiseql-semis 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.
Files changed (107) hide show
  1. fraiseql_semis-0.1.0/.gitignore +70 -0
  2. fraiseql_semis-0.1.0/CHANGELOG.md +41 -0
  3. fraiseql_semis-0.1.0/LICENSE +21 -0
  4. fraiseql_semis-0.1.0/PKG-INFO +370 -0
  5. fraiseql_semis-0.1.0/README.md +334 -0
  6. fraiseql_semis-0.1.0/docs/ARCHITECTURE.md +646 -0
  7. fraiseql_semis-0.1.0/docs/PRD.md +156 -0
  8. fraiseql_semis-0.1.0/pyproject.toml +138 -0
  9. fraiseql_semis-0.1.0/scenarios/minimal_seed.yaml +16 -0
  10. fraiseql_semis-0.1.0/src/fraiseql_semis/__init__.py +106 -0
  11. fraiseql_semis-0.1.0/src/fraiseql_semis/cli/__init__.py +5 -0
  12. fraiseql_semis-0.1.0/src/fraiseql_semis/cli/main.py +493 -0
  13. fraiseql_semis-0.1.0/src/fraiseql_semis/cli/options.py +110 -0
  14. fraiseql_semis-0.1.0/src/fraiseql_semis/codes.py +59 -0
  15. fraiseql_semis-0.1.0/src/fraiseql_semis/emit.py +154 -0
  16. fraiseql_semis-0.1.0/src/fraiseql_semis/errors.py +97 -0
  17. fraiseql_semis-0.1.0/src/fraiseql_semis/faker_provider.py +254 -0
  18. fraiseql_semis-0.1.0/src/fraiseql_semis/generator.py +416 -0
  19. fraiseql_semis-0.1.0/src/fraiseql_semis/hierarchy.py +100 -0
  20. fraiseql_semis-0.1.0/src/fraiseql_semis/pin.py +199 -0
  21. fraiseql_semis-0.1.0/src/fraiseql_semis/project.py +385 -0
  22. fraiseql_semis-0.1.0/src/fraiseql_semis/providers/__init__.py +17 -0
  23. fraiseql_semis-0.1.0/src/fraiseql_semis/providers/i18n.py +113 -0
  24. fraiseql_semis-0.1.0/src/fraiseql_semis/providers/organization.py +98 -0
  25. fraiseql_semis-0.1.0/src/fraiseql_semis/readback.py +98 -0
  26. fraiseql_semis-0.1.0/src/fraiseql_semis/resolution.py +167 -0
  27. fraiseql_semis-0.1.0/src/fraiseql_semis/rows.py +101 -0
  28. fraiseql_semis-0.1.0/src/fraiseql_semis/scenario.py +644 -0
  29. fraiseql_semis-0.1.0/src/fraiseql_semis/schema.py +211 -0
  30. fraiseql_semis-0.1.0/src/fraiseql_semis/seeds.py +100 -0
  31. fraiseql_semis-0.1.0/src/fraiseql_semis/staging.py +77 -0
  32. fraiseql_semis-0.1.0/src/fraiseql_semis/uuid_generator.py +65 -0
  33. fraiseql_semis-0.1.0/tests/__init__.py +0 -0
  34. fraiseql_semis-0.1.0/tests/conftest.py +26 -0
  35. fraiseql_semis-0.1.0/tests/contract/__init__.py +0 -0
  36. fraiseql_semis-0.1.0/tests/contract/test_apply_reads_a_tree.py +34 -0
  37. fraiseql_semis-0.1.0/tests/contract/test_column_facts.py +37 -0
  38. fraiseql_semis-0.1.0/tests/contract/test_copy_seed_is_checked.py +49 -0
  39. fraiseql_semis-0.1.0/tests/contract/test_database_url_precedence.py +106 -0
  40. fraiseql_semis-0.1.0/tests/contract/test_diff_reads_sources.py +49 -0
  41. fraiseql_semis-0.1.0/tests/contract/test_exit_codes.py +41 -0
  42. fraiseql_semis-0.1.0/tests/contract/test_final_table_per_resolver.py +52 -0
  43. fraiseql_semis-0.1.0/tests/contract/test_level_1_uuid_check.py +24 -0
  44. fraiseql_semis-0.1.0/tests/contract/test_model_json_is_not_cross_source_stable.py +69 -0
  45. fraiseql_semis-0.1.0/tests/contract/test_parse_schema.py +32 -0
  46. fraiseql_semis-0.1.0/tests/contract/test_platform_surface.py +49 -0
  47. fraiseql_semis-0.1.0/tests/contract/test_prep_seed_convention.py +60 -0
  48. fraiseql_semis-0.1.0/tests/contract/test_prep_seed_level_3.py +87 -0
  49. fraiseql_semis-0.1.0/tests/contract/test_prep_seed_level_3_unreferenced.py +74 -0
  50. fraiseql_semis-0.1.0/tests/contract/test_prep_seed_level_5.py +81 -0
  51. fraiseql_semis-0.1.0/tests/contract/test_self_reference.py +23 -0
  52. fraiseql_semis-0.1.0/tests/contract/test_transaction_ownership.py +64 -0
  53. fraiseql_semis-0.1.0/tests/contract/test_writer_refusals.py +39 -0
  54. fraiseql_semis-0.1.0/tests/contract/test_writers_do_not_validate.py +46 -0
  55. fraiseql_semis-0.1.0/tests/ddl.py +115 -0
  56. fraiseql_semis-0.1.0/tests/docs/__init__.py +0 -0
  57. fraiseql_semis-0.1.0/tests/docs/test_examples.py +663 -0
  58. fraiseql_semis-0.1.0/tests/integration/__init__.py +0 -0
  59. fraiseql_semis-0.1.0/tests/integration/test_cli_apply.py +168 -0
  60. fraiseql_semis-0.1.0/tests/integration/test_hierarchy.py +109 -0
  61. fraiseql_semis-0.1.0/tests/integration/test_one_table_end_to_end.py +66 -0
  62. fraiseql_semis-0.1.0/tests/integration/test_providers.py +93 -0
  63. fraiseql_semis-0.1.0/tests/integration/test_read_back.py +147 -0
  64. fraiseql_semis-0.1.0/tests/integration/test_scale.py +65 -0
  65. fraiseql_semis-0.1.0/tests/integration/test_scenario_execution.py +148 -0
  66. fraiseql_semis-0.1.0/tests/integration/test_validate_seeds.py +190 -0
  67. fraiseql_semis-0.1.0/tests/performance/__init__.py +0 -0
  68. fraiseql_semis-0.1.0/tests/performance/test_generation_rate.py +138 -0
  69. fraiseql_semis-0.1.0/tests/unit/__init__.py +0 -0
  70. fraiseql_semis-0.1.0/tests/unit/fake_providers.py +21 -0
  71. fraiseql_semis-0.1.0/tests/unit/guards.py +25 -0
  72. fraiseql_semis-0.1.0/tests/unit/test_cli.py +555 -0
  73. fraiseql_semis-0.1.0/tests/unit/test_cli_has_no_generation.py +83 -0
  74. fraiseql_semis-0.1.0/tests/unit/test_cli_options.py +40 -0
  75. fraiseql_semis-0.1.0/tests/unit/test_codes.py +48 -0
  76. fraiseql_semis-0.1.0/tests/unit/test_emit.py +63 -0
  77. fraiseql_semis-0.1.0/tests/unit/test_errors.py +30 -0
  78. fraiseql_semis-0.1.0/tests/unit/test_faker_provider.py +188 -0
  79. fraiseql_semis-0.1.0/tests/unit/test_generator.py +111 -0
  80. fraiseql_semis-0.1.0/tests/unit/test_hierarchy.py +200 -0
  81. fraiseql_semis-0.1.0/tests/unit/test_nothing_is_evaluated.py +55 -0
  82. fraiseql_semis-0.1.0/tests/unit/test_one_code_registry.py +64 -0
  83. fraiseql_semis-0.1.0/tests/unit/test_one_confiture_seam.py +36 -0
  84. fraiseql_semis-0.1.0/tests/unit/test_one_sql_site.py +74 -0
  85. fraiseql_semis-0.1.0/tests/unit/test_package.py +51 -0
  86. fraiseql_semis-0.1.0/tests/unit/test_pin.py +141 -0
  87. fraiseql_semis-0.1.0/tests/unit/test_project.py +385 -0
  88. fraiseql_semis-0.1.0/tests/unit/test_providers.py +229 -0
  89. fraiseql_semis-0.1.0/tests/unit/test_pure_modules.py +37 -0
  90. fraiseql_semis-0.1.0/tests/unit/test_readback.py +40 -0
  91. fraiseql_semis-0.1.0/tests/unit/test_resolution.py +208 -0
  92. fraiseql_semis-0.1.0/tests/unit/test_rows.py +118 -0
  93. fraiseql_semis-0.1.0/tests/unit/test_scenario.py +241 -0
  94. fraiseql_semis-0.1.0/tests/unit/test_scenario_run.py +283 -0
  95. fraiseql_semis-0.1.0/tests/unit/test_schema_facts.py +73 -0
  96. fraiseql_semis-0.1.0/tests/unit/test_seeds.py +94 -0
  97. fraiseql_semis-0.1.0/tests/unit/test_shipped_scenarios.py +26 -0
  98. fraiseql_semis-0.1.0/tests/unit/test_staging.py +110 -0
  99. fraiseql_semis-0.1.0/tests/unit/test_toolchain.py +55 -0
  100. fraiseql_semis-0.1.0/tests/unit/test_uuid_generator.py +62 -0
  101. fraiseql_semis-0.1.0/tests/unit/test_validate_seeds.py +45 -0
  102. fraiseql_semis-0.1.0/tests/unit/test_warehouse.py +93 -0
  103. fraiseql_semis-0.1.0/tests/warehouse/scenarios/stock.yaml +39 -0
  104. fraiseql_semis-0.1.0/tests/warehouse/schema/010_parents.sql +11 -0
  105. fraiseql_semis-0.1.0/tests/warehouse/schema/020_catalog.sql +98 -0
  106. fraiseql_semis-0.1.0/tests/warehouse/schema/030_prep_seed.sql +92 -0
  107. fraiseql_semis-0.1.0/tests/warehouse/semis.yaml +14 -0
@@ -0,0 +1,70 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ pip-wheel-metadata/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+
26
+ # Virtual environments
27
+ venv/
28
+ ENV/
29
+ env/
30
+ .venv
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+ *~
38
+ .DS_Store
39
+
40
+ # Testing
41
+ .pytest_cache/
42
+ .coverage
43
+ .coverage.*
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+
48
+ # Type checking
49
+ .pytype/
50
+
51
+ # Ruff
52
+ .ruff_cache/
53
+
54
+ # Documentation
55
+ docs/_build/
56
+ site/
57
+
58
+ # Local configuration
59
+ *.local.yaml
60
+ *.local.yml
61
+ .env
62
+ .env.local
63
+
64
+ # Database
65
+ *.db
66
+ *.sqlite
67
+ *.sqlite3
68
+
69
+ # Logs
70
+ *.log
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to fraiseql-semis are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-09-30
8
+
9
+ The first release.
10
+
11
+ ### Added
12
+
13
+ - **Semantic UUIDs**: each row's `id` encodes its table code, scenario id, version and
14
+ sequence; `semis decode-uuid` reads one back, naming the table and the scenario.
15
+ - **Values from the schema's facts**, read through `confiture.platform`: type, declared
16
+ length, enum labels, NOT NULL and UNIQUE; foreign keys from their `REFERENCES`.
17
+ - **Two declared FK modes**: prep-seed (a child carries its parent's UUID, written into
18
+ the staging twin) and read-back (parents applied, their `pk_*` read back by UUID).
19
+ - **The row contract**: a row missing a NOT NULL column without a default is refused,
20
+ with a per-column `trusts_trigger:` opt-out.
21
+ - **YAML scenarios** with overrides (a value, a list, a callable, or `null` for a
22
+ nullable key), per-column providers, and hierarchies for self-referencing tables,
23
+ including `ltree` paths in read-back.
24
+ - **Schema pins**: a digest of the facts semis consumes, refusing a replay against a
25
+ schema that has moved and reporting what changed.
26
+ - **Provider libraries**: `i18n` and `organization` shipped; a project's own providers
27
+ as `module:attribute`; installed libraries enabled by name through the
28
+ `fraiseql_semis.providers` entry point.
29
+ - **The `semis` command**: `seeds`, `apply`, `generate`, `table`, `validate`,
30
+ `validate-seeds` (confiture's five prep-seed levels), `list-scenarios`,
31
+ `init-scenario` and `decode-uuid`, configured by `semis.yaml`.
32
+ - **Scale**: 21,500 rows per second generated; 100,000 rows written and applied in 15
33
+ seconds, streamed rather than held.
34
+
35
+ ### Security
36
+
37
+ - **A database URL's password is never printed.** semis masks it in whatever a refusal
38
+ prints, and requires fraiseql-confiture 1.27, whose `CONFIG_003` refusal of a
39
+ `--database-url` no longer repeats the password either.
40
+
41
+ [0.1.0]: https://github.com/fraiseql/semis/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lionel Hamayon
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,370 @@
1
+ Metadata-Version: 2.5
2
+ Name: fraiseql-semis
3
+ Version: 0.1.0
4
+ Summary: Semantic UUID-encoded fake data generator for PostgreSQL trinity pattern schemas
5
+ Project-URL: Homepage, https://github.com/fraiseql/semis
6
+ Project-URL: Documentation, https://fraiseql.dev/semis
7
+ Project-URL: Repository, https://github.com/fraiseql/semis
8
+ Project-URL: Bug Tracker, https://github.com/fraiseql/semis/issues
9
+ Author-email: Lionel Hamayon <lionel@fraiseql.dev>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: database,fake-data,faker,fraiseql,postgresql,seeding,testing,uuid
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: faker>=24.0.0
25
+ Requires-Dist: fraiseql-confiture<2,>=1.27
26
+ Requires-Dist: psycopg[binary]>=3.1.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: typer>=0.12
29
+ Provides-Extra: dev
30
+ Requires-Dist: pre-commit>=3.6.0; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.3.0; extra == 'dev'
34
+ Requires-Dist: ty>=0.0.83; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # fraiseql-semis
38
+
39
+ **Reproducible seed and test data for PostgreSQL trinity-pattern schemas.**
40
+
41
+ semis generates rows: which rows exist, what each value looks like, and what UUID
42
+ identifies each one. It decides nothing about the schema those rows land in — every
43
+ question of the form *"what does the schema say?"* goes to
44
+ [confiture](https://github.com/fraiseql/confiture), which reads the DDL or the live
45
+ database, orders tables by their real foreign keys, says which columns a writer may supply
46
+ and what each value must respect, and writes, applies and validates the seed files.
47
+
48
+ ---
49
+
50
+ ## What it does
51
+
52
+ - 🎯 **Semantic UUIDs** — every `id` encodes its table, scenario, version and sequence, so
53
+ a row in a dump tells you where it came from; the `identifier` slug carries the scenario
54
+ too, so two scenarios can share a database
55
+ - 🎲 **Faker, driven by facts** — a value satisfies the column that receives it: its type,
56
+ its enum values, its declared length, its NOT NULL
57
+ - 🔗 **Foreign keys that are real** — the parent table and column come from the schema's
58
+ actual foreign key, never from the spelling of `fk_continent`
59
+ - 📋 **YAML scenarios** — a run is a reviewable file, pinned to the schema it was written
60
+ against
61
+ - 🔁 **Reproducible** — same scenario, same seed, same schema, same bytes
62
+ - 🚫 **No invented keys** — PostgreSQL fills `pk_*`; semis never writes one
63
+
64
+ ## What it does not do
65
+
66
+ semis reads no `information_schema`, sorts no tables, and writes no SQL by hand. All three
67
+ are confiture's, and going through it is why `VARCHAR(50)` gets fifty characters and why a
68
+ `REFERENCES` clause is read rather than guessed.
69
+
70
+ ---
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ uv add fraiseql-semis # brings fraiseql-confiture>=1.27,<2
76
+ ```
77
+
78
+ ## The chain, end to end
79
+
80
+ This is the whole idea in one screen — generate, write, apply, read back:
81
+
82
+ ```python
83
+ from pathlib import Path
84
+
85
+ from fraiseql_semis import FakeDataGenerator, SchemaFacts, TableCodes, seeds
86
+
87
+ database_url = "postgresql:///myproject_dev"
88
+ codes = TableCodes({"catalog.tb_continent": 0x02030405})
89
+
90
+ # the project's DDL tree; SchemaFacts.from_env("development", table_codes=codes) reads
91
+ # a confiture environment's build, and .from_database(url, …) a live database
92
+ facts = SchemaFacts.from_source(Path("db/schema"), table_codes=codes)
93
+ gen = FakeDataGenerator(facts, scenario_id=0x5001, seed=42)
94
+
95
+ rows = gen.generate_rows(
96
+ "catalog.tb_continent",
97
+ count=3,
98
+ overrides={"identifier": lambda i: f"cont-{i + 1}", "name": lambda i: f"Continent {i + 1}"},
99
+ )
100
+ seed = seeds.write(
101
+ "db/seeds/010_tb_continent.sql", "catalog.tb_continent", rows, facts=facts, mode="read-back"
102
+ )
103
+ seeds.apply(database_url, [seed.path])
104
+ ```
105
+
106
+ What arrives in PostgreSQL — `psql` output from exactly this chain. The two key columns
107
+ are the point:
108
+
109
+ ```
110
+ pk_continent | id | identifier | name
111
+ --------------+--------------------------------------+------------+-------------
112
+ 1 | 02030405-5001-0001-0000-000000000001 | cont-1 | Continent 1
113
+ 2 | 02030405-5001-0001-0000-000000000002 | cont-2 | Continent 2
114
+ 3 | 02030405-5001-0001-0000-000000000003 | cont-3 | Continent 3
115
+ (3 rows)
116
+ ```
117
+
118
+ `pk_continent` is PostgreSQL's — semis could not write it if it wanted to:
119
+
120
+ ```python
121
+ >>> write_copy_seed(path, "catalog.tb_continent", ["pk_continent", "id"], rows, model=model)
122
+ SeedError: PostgreSQL fills catalog.tb_continent.pk_continent (an identity, generated or
123
+ serial column): leave it out of the seed
124
+ ```
125
+
126
+ `id` is semis' — and it decodes:
127
+
128
+ ```bash
129
+ $ semis decode-uuid 02030405-5001-0001-0000-000000000001
130
+ table_code 0x02030405 catalog.tb_continent
131
+ scenario_id 0x5001 minimal_seed
132
+ version 1
133
+ sequence 1
134
+ ```
135
+
136
+ ---
137
+
138
+ ## The two FK modes
139
+
140
+ A child needs its parent's integer `pk_*`, and semis cannot choose one. **A scenario
141
+ declares which of the two ways it closes that gap**; semis refuses to guess, because a
142
+ scenario that picks its mode by probing the database means two different things in CI and
143
+ on a laptop.
144
+
145
+ | | **prep-seed** | **read-back** |
146
+ |---|---|---|
147
+ | Rows are written into | the staging twin, `prep_seed.<table>` | the table itself |
148
+ | A child's FK carries | the parent's **UUID**, as `<fk>_id` | the parent's **integer**, learned after apply |
149
+ | Database needed to generate | no | yes |
150
+ | Translation done by | the project's `fn_resolve_*` functions | a join on the UUID semis wrote |
151
+ | Output is | byte-reproducible | reproducible except the FK integers, which are PostgreSQL's |
152
+ | Default writer | `INSERT` (confiture's prep-seed level 1 reads only INSERT) | `COPY` |
153
+
154
+ A prep-seed scenario names the catalog tables, whose foreign keys, constraints and pin
155
+ semis reads, and writes each into its **staging twin**: the same table name in the
156
+ project's staging schema, every foreign key a UUID named `<fk>_id` — the layout
157
+ confiture's prep-seed validation checks and a project's `fn_resolve_*` functions read.
158
+ A table without its twin is refused before a row is drawn.
159
+
160
+ ```yaml
161
+ # scenarios/minimal_seed.yaml
162
+ scenario_id: 0x5001
163
+ name: minimal_seed
164
+ description: Seven continents and the countries on them
165
+ mode: prep-seed # required: prep-seed | read-back
166
+ locale: en_US
167
+ seed: 42
168
+
169
+ tables:
170
+ - name: catalog.tb_continent
171
+ count: 7
172
+ overrides:
173
+ name: [Africa, Antarctica, Asia, Europe, North America, Oceania, South America]
174
+ - name: catalog.tb_country
175
+ count: 50
176
+ trusts_trigger: [created_by] # a trigger fills it; do not refuse the row
177
+ ```
178
+
179
+ Every foreign key's parent is generated in the same run — a missing one is refused
180
+ before a row is drawn — unless the key is nullable and the scenario leaves it `NULL`:
181
+
182
+ ```yaml
183
+ - name: inventory.tb_item
184
+ count: 50
185
+ overrides: {fk_account: null, fk_order: null} # no account, no order
186
+ ```
187
+
188
+ From Python, a scenario runs through `ScenarioManager`, which writes one seed file per
189
+ table and, beside them, `schema_pin.yaml` and the DDL it digested, `schema_pin.ddl`: the
190
+ block and the snapshot to copy beside the scenario so a replay against a moved schema is
191
+ refused.
192
+
193
+ ```python
194
+ from fraiseql_semis import ScenarioManager
195
+
196
+ manager = ScenarioManager(facts) # the SchemaFacts above
197
+ run = manager.execute(manager.load("scenarios/minimal_seed.yaml"), Path("db/seeds/prep"))
198
+ print(*run.notices) # scenario minimal_seed is unpinned: its schema is not checked
199
+ ```
200
+
201
+ From the command line, a project names its schema and its table codes once, in
202
+ `semis.yaml`; a scenario names neither:
203
+
204
+ ```yaml
205
+ # semis.yaml
206
+ schema:
207
+ env: development # a confiture environment; or ddl: db/schema/ or
208
+ # database: {schemas: [catalog]}
209
+ scenarios: scenarios/
210
+ table_codes: # or a path to a YAML file of them
211
+ catalog.tb_continent: 0x02030405
212
+ catalog.tb_country: 0x03040506
213
+ providers: [i18n, organization, myproject.fake:PROVIDERS] # optional; see Providers
214
+ prep_seed:
215
+ prep_seed_schema: prep_seed # where prep-seed scenarios write; the default
216
+ schema_dir: db/0_schema # the tree holding the resolvers; default: a ddl: directory
217
+ catalog_schema: catalog # optional: a final table's fallback schema
218
+ ```
219
+
220
+ ```bash
221
+ semis seeds scenarios/minimal_seed.yaml -o db/seeds/prep # prep-seed: writes files, no database
222
+ semis seeds scenarios/minimal_seed.yaml --dry-run # every row checked, nothing written
223
+ semis apply scenarios/minimal_seed.yaml -o db/seeds/run # writes and applies, one transaction
224
+ semis generate scenarios/minimal_seed.yaml -o db/seeds/run # seeds for prep-seed, apply for read-back
225
+ semis validate scenarios/minimal_seed.yaml # schema and pin checked, no rows drawn
226
+ semis validate-seeds scenarios/minimal_seed.yaml # confiture's five prep-seed levels
227
+ semis validate-seeds --seeds db/seeds/prep --max-level 3 # a directory's seeds, files only
228
+ semis table catalog.tb_continent --count 3 --mode prep-seed --scenario-id 0x5001 -o out
229
+ semis list-scenarios
230
+ semis init-scenario demo --mode read-back # a template, with the next free id
231
+ ```
232
+
233
+ `apply` connects where confiture would: `--database-url`, then `CONFITURE_DATABASE_URL`,
234
+ then the `env:` file's `database_url`; the ambient `DATABASE_URL` alone is refused to a
235
+ command that writes. The whole run is one transaction, committed at the end, and
236
+ `apply --dry-run` runs it and rolls it back. A refusal exits 1, naming the table and the
237
+ column; a confiture error exits with confiture's own code and hint.
238
+
239
+ `validate-seeds` rehearses a prep-seed scenario into a temporary directory and has
240
+ confiture judge exactly its files; `--seeds DIR` judges a directory instead. Levels 1–3
241
+ read files. Levels 4–5 load the seeds into the staging twins and run the project's
242
+ resolvers on the database, found as `apply` finds it, inside a savepoint rolled back:
243
+ nothing is left behind. With no database URL it runs levels 1–3 and says so. Findings are
244
+ printed most severe first, each with its file and line and confiture's hint, and a
245
+ `CRITICAL` or `ERROR` exits 1.
246
+
247
+ `semis.yaml` loads into `fraiseql_semis.Project`, which a tool that drives semis from
248
+ Python builds directly, with no file.
249
+
250
+ ### Providers
251
+
252
+ semis ships two provider libraries — `i18n` (ISO country, language and currency codes,
253
+ locales, time zones) and `organization` (company names, SIREN/SIRET and French VAT
254
+ numbers that check, job titles, contacts). A library named under
255
+ `providers:` draws every column its rules match by name and type — `lang VARCHAR(2)`
256
+ receives `fr`, a `MACADDR` column a MAC address — and never a value longer than the
257
+ column holds. A scenario names one provider for one column:
258
+
259
+ ```yaml
260
+ - name: catalog.tb_currency
261
+ count: 20
262
+ providers: {iso_code: i18n.currency_code, name: slogan}
263
+ ```
264
+
265
+ `slogan` is the project's own: `myproject.fake:PROVIDERS` is a mapping of names to
266
+ functions taking the run's seeded `Faker` and the column's facts. It is imported; no
267
+ scenario string is ever evaluated.
268
+
269
+ ### Your own provider library
270
+
271
+ A library another project can install — private to a company, or published — is a
272
+ `Library` registered under the `fraiseql_semis.providers` entry point:
273
+
274
+ ```python
275
+ # acme_semis/__init__.py
276
+ from faker import Faker
277
+
278
+ from fraiseql_semis import TEXT_TYPES, ColumnFacts, Library, Rule
279
+
280
+
281
+ def ticker(faker: Faker, _column: ColumnFacts) -> str:
282
+ return faker.lexify("????").upper()
283
+
284
+
285
+ LIBRARY = Library(
286
+ "acme", # the name semis.yaml enables it by
287
+ {"ticker": ticker}, # a scenario names acme.ticker
288
+ rules=(Rule("ticker", names=frozenset({"ticker", "symbol"}), types=TEXT_TYPES),),
289
+ )
290
+ ```
291
+
292
+ ```toml
293
+ # acme-semis's pyproject.toml
294
+ [project.entry-points."fraiseql_semis.providers"]
295
+ acme = "acme_semis:LIBRARY"
296
+ ```
297
+
298
+ Installed beside semis (`uv add acme-semis`), it is enabled as a shipped one is —
299
+ `providers: [i18n, acme]` — and only then: installing a library enables nothing, and
300
+ only the libraries `semis.yaml` names are imported. A name two installed packages
301
+ claim, or a shipped name, is refused, naming the packages.
302
+
303
+ ### Hierarchies
304
+
305
+ A table whose foreign key points at itself — a location inside a location — declares its
306
+ tree, and semis draws it breadth-first: the roots carry `NULL`, and every later row hangs
307
+ from an earlier one, `fan_out` children to a parent.
308
+
309
+ ```yaml
310
+ - name: catalog.tb_location
311
+ count: 20 # levels of 2, 6 and 12 rows
312
+ hierarchy:
313
+ parent: fk_parent_location # the nullable self-FK that builds the tree
314
+ roots: 2
315
+ fan_out: 3
316
+ path: path # read-back only: an ltree of pk_* labels, 1.5.12
317
+ ```
318
+
319
+ In prep-seed a child carries its parent's UUID, like any child. In read-back each level is
320
+ written, applied and read back before the next is drawn, one seed file per level, and
321
+ `path:` is set from the keys PostgreSQL gave: the parent's path, a dot, the row's own
322
+ `pk_*`. A self-referencing table without a `hierarchy:` is refused.
323
+
324
+ ---
325
+
326
+ ## Two things semis insists on
327
+
328
+ **A row is complete before it is written.** Confiture's writers do not check NOT NULL —
329
+ deliberately, because a trigger may fill a column and the schema model cannot know. So
330
+ semis checks: a column that is NOT NULL with no default must be in the row with a real
331
+ value, or the row is refused, naming the table, the column and the fact it failed. A
332
+ column a trigger really does fill is named in `trusts_trigger:`, per table, per column.
333
+ Without this the gap surfaces at apply time as a constraint violation against a file, with
334
+ no line and no generator in sight.
335
+
336
+ **A scenario knows the schema it was written against.** Each one records a digest of the
337
+ facts semis consumes — writable columns, their types, nullability, defaults, enums, checks,
338
+ FK targets — and a replay against a moved schema is refused, with `confiture.diff`
339
+ reporting what changed. Adding an index does not move the digest; adding `NOT NULL` to a
340
+ column semis writes does. `--no-pin` skips the check for one run and says so.
341
+
342
+ ---
343
+
344
+ ## Documentation
345
+
346
+ | | |
347
+ |---|---|
348
+ | [docs/PRD.md](./docs/PRD.md) | What semis is for, and for whom |
349
+ | [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | The boundaries, the two modes, determinism, the pin, the decision log |
350
+
351
+ ## Development
352
+
353
+ ```bash
354
+ uv sync --all-extras
355
+ uv run pytest tests/unit/ # no database needed
356
+ uv run ruff check . && uv run ruff format --check .
357
+ uv run ty check src/fraiseql_semis/
358
+
359
+ export SEMIS_TEST_DATABASE_URL=postgresql:///semis_test
360
+ uv run pytest tests/integration/
361
+ ```
362
+
363
+ Unit tests take their schema from a DDL string through `parse_schema`, so the generator,
364
+ the row contract and the UUID encoding are all testable without PostgreSQL. Tests under
365
+ `tests/contract/` pin the confiture behaviours semis relies on, so an upstream change
366
+ fails here with a name.
367
+
368
+ ## License
369
+
370
+ MIT — see [LICENSE](./LICENSE).