model2data 0.4.3__tar.gz → 0.5.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.
- {model2data-0.4.3 → model2data-0.5.0}/PKG-INFO +11 -5
- {model2data-0.4.3 → model2data-0.5.0}/README.md +7 -1
- {model2data-0.4.3 → model2data-0.5.0}/model2data/cli.py +13 -2
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/project.py +5 -1
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/tests.py +64 -61
- {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/core.py +31 -4
- {model2data-0.4.3 → model2data-0.5.0}/model2data/parse/dbml.py +208 -39
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/PKG-INFO +11 -5
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/requires.txt +3 -3
- {model2data-0.4.3 → model2data-0.5.0}/pyproject.toml +4 -4
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_cli.py +68 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbml_parser.py +301 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_project.py +17 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_tests.py +93 -11
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_generation.py +103 -1
- {model2data-0.4.3 → model2data-0.5.0}/LICENSE +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/__init__.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/__init__.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/__init__.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/faker.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/relationships.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/parse/__init__.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data/utils.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/SOURCES.txt +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/setup.cfg +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_integration.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_naming.py +0 -0
- {model2data-0.4.3 → model2data-0.5.0}/tests/test_faker_name_inference.py +0 -0
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Requires-Dist: dbt-core>=1.5
|
|
9
|
-
Requires-Dist: dbt-duckdb>=1.
|
|
8
|
+
Requires-Dist: dbt-core>=1.8.5
|
|
9
|
+
Requires-Dist: dbt-duckdb>=1.8.4
|
|
10
10
|
Requires-Dist: faker>=37.12.0
|
|
11
11
|
Requires-Dist: pandas>=2.3.3
|
|
12
12
|
Requires-Dist: pyyaml>=6.0.3
|
|
13
13
|
Requires-Dist: typer>=0.20.0
|
|
14
14
|
Provides-Extra: postgres
|
|
15
|
-
Requires-Dist: dbt-postgres>=1.
|
|
15
|
+
Requires-Dist: dbt-postgres>=1.8.0; extra == "postgres"
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: pytest; extra == "dev"
|
|
18
18
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -177,7 +177,13 @@ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`mode
|
|
|
177
177
|
model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
This targets dbt-core's native unit testing feature, which requires
|
|
180
|
+
This targets dbt-core's native unit testing feature, which requires dbt-core >= 1.8 — already
|
|
181
|
+
covered by this project's `dbt-core>=1.8.5` floor, so `--unit-tests` works with the base install.
|
|
182
|
+
Note: on dbt-core versions in the 1.8.x line specifically, a DBML column named after a SQL
|
|
183
|
+
reserved word (e.g. `by`, as in `examples/hackernews.dbml`) can fail unit test execution with a
|
|
184
|
+
`syntax error` — a dbt-core-internal identifier-quoting limitation in its unit test fixture
|
|
185
|
+
rendering for that release line, not something under model2data's control. It's fixed in later
|
|
186
|
+
dbt-core versions; every other `--unit-tests` path works fine on 1.8.x.
|
|
181
187
|
|
|
182
188
|
---
|
|
183
189
|
|
|
@@ -152,7 +152,13 @@ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`mode
|
|
|
152
152
|
model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
This targets dbt-core's native unit testing feature, which requires
|
|
155
|
+
This targets dbt-core's native unit testing feature, which requires dbt-core >= 1.8 — already
|
|
156
|
+
covered by this project's `dbt-core>=1.8.5` floor, so `--unit-tests` works with the base install.
|
|
157
|
+
Note: on dbt-core versions in the 1.8.x line specifically, a DBML column named after a SQL
|
|
158
|
+
reserved word (e.g. `by`, as in `examples/hackernews.dbml`) can fail unit test execution with a
|
|
159
|
+
`syntax error` — a dbt-core-internal identifier-quoting limitation in its unit test fixture
|
|
160
|
+
rendering for that release line, not something under model2data's control. It's fixed in later
|
|
161
|
+
dbt-core versions; every other `--unit-tests` path works fine on 1.8.x.
|
|
156
162
|
|
|
157
163
|
---
|
|
158
164
|
|
|
@@ -12,9 +12,9 @@ from model2data.dbt.project import (
|
|
|
12
12
|
create_staging_models,
|
|
13
13
|
)
|
|
14
14
|
from model2data.dbt.tests import generate_dbt_yml, generate_unit_tests
|
|
15
|
-
from model2data.generate.core import generate_data_from_dbml
|
|
15
|
+
from model2data.generate.core import generate_data_from_dbml, get_cyclic_tables
|
|
16
16
|
from model2data.generate.faker import get_unmapped_columns, reset_stats
|
|
17
|
-
from model2data.parse.dbml import parse_dbml
|
|
17
|
+
from model2data.parse.dbml import get_parse_warnings, parse_dbml
|
|
18
18
|
from model2data.utils import normalize_identifier
|
|
19
19
|
|
|
20
20
|
SUPPORTED_ADAPTERS = ("duckdb", "postgres")
|
|
@@ -111,6 +111,7 @@ def main(
|
|
|
111
111
|
# Parse DBML (names untouched)
|
|
112
112
|
# -------------------------
|
|
113
113
|
tables, refs = parse_dbml(file)
|
|
114
|
+
parse_warnings = get_parse_warnings()
|
|
114
115
|
if not tables:
|
|
115
116
|
typer.echo("❌ No tables found in the provided DBML file.")
|
|
116
117
|
raise typer.Exit(1)
|
|
@@ -175,6 +176,7 @@ def main(
|
|
|
175
176
|
# -------------------------
|
|
176
177
|
total_rows = sum(len(df) for df in generated_tables.values())
|
|
177
178
|
unmapped = get_unmapped_columns()
|
|
179
|
+
cyclic_tables = get_cyclic_tables()
|
|
178
180
|
|
|
179
181
|
typer.echo("\n📊 Summary")
|
|
180
182
|
typer.echo(f" Tables generated: {len(generated_tables)}")
|
|
@@ -186,6 +188,15 @@ def main(
|
|
|
186
188
|
typer.echo(f" - {col_name} ({data_type})")
|
|
187
189
|
else:
|
|
188
190
|
typer.echo(" Columns using generic fallback text: 0")
|
|
191
|
+
if cyclic_tables:
|
|
192
|
+
typer.echo(
|
|
193
|
+
" ⚠️ Tables in an unresolved FK cycle (data may not respect "
|
|
194
|
+
f"all relationships): {', '.join(cyclic_tables)}"
|
|
195
|
+
)
|
|
196
|
+
if parse_warnings:
|
|
197
|
+
typer.echo(f" ⚠️ DBML lines model2data could not fully parse: {len(parse_warnings)}")
|
|
198
|
+
for warning in parse_warnings:
|
|
199
|
+
typer.echo(f" - {warning}")
|
|
189
200
|
|
|
190
201
|
# -------------------------
|
|
191
202
|
# Done
|
|
@@ -45,10 +45,14 @@ def create_staging_models(dest: Path, project_name: str) -> None:
|
|
|
45
45
|
model_file = models_path / f"stg_{table_name}.sql"
|
|
46
46
|
|
|
47
47
|
if not model_file.exists():
|
|
48
|
+
# table_name is spliced into a single-quoted Jinja string literal;
|
|
49
|
+
# escape any embedded single quote so a DBML identifier containing
|
|
50
|
+
# one can't break the source() call.
|
|
51
|
+
escaped_name = table_name.replace("'", "\\'")
|
|
48
52
|
sql_content = f"""\
|
|
49
53
|
-- Auto-generated staging model for {table_name}
|
|
50
54
|
select *
|
|
51
|
-
from {{{{ source('raw', '{
|
|
55
|
+
from {{{{ source('raw', '{escaped_name}') }}}}
|
|
52
56
|
"""
|
|
53
57
|
model_file.write_text(sql_content)
|
|
54
58
|
|
|
@@ -10,10 +10,16 @@ from model2data.generate.faker import is_free_text_type
|
|
|
10
10
|
from model2data.generate.relationships import classify_refs
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
def _dump_yaml(data: dict) -> str:
|
|
14
|
+
"""Dump a plain Python structure to YAML, safely escaping every value.
|
|
15
|
+
|
|
16
|
+
Every table/column/description string that ends up in generated YAML
|
|
17
|
+
goes through this single choke point instead of being hand-interpolated
|
|
18
|
+
into f-string lines, so an arbitrary (but valid) DBML identifier --
|
|
19
|
+
containing a space, colon, quote, etc. -- can never produce invalid or
|
|
20
|
+
silently-misparsed YAML.
|
|
21
|
+
"""
|
|
22
|
+
return yaml.safe_dump(data, default_flow_style=False, sort_keys=False)
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: str = "hackernews"):
|
|
@@ -53,20 +59,25 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
|
|
|
53
59
|
# -------------------------
|
|
54
60
|
# Generate __sources.yml
|
|
55
61
|
# -------------------------
|
|
56
|
-
|
|
57
|
-
sources_lines.append(" - name: raw")
|
|
58
|
-
sources_lines.append(" schema: raw")
|
|
59
|
-
sources_lines.append(f" description: {source_name.capitalize()} raw seed data")
|
|
60
|
-
sources_lines.append(" tables:")
|
|
61
|
-
|
|
62
|
+
source_tables = []
|
|
62
63
|
for table in tables.values():
|
|
63
64
|
seed_name = table.name # keep exact name
|
|
64
65
|
table_desc = getattr(table, "description", None) or f"Table {seed_name}"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
source_tables.append({"name": seed_name, "description": table_desc})
|
|
67
|
+
|
|
68
|
+
sources_doc = {
|
|
69
|
+
"version": 2,
|
|
70
|
+
"sources": [
|
|
71
|
+
{
|
|
72
|
+
"name": "raw",
|
|
73
|
+
"schema": "raw",
|
|
74
|
+
"description": f"{source_name.capitalize()} raw seed data",
|
|
75
|
+
"tables": source_tables,
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
}
|
|
68
79
|
sources_file = staging_path / "__sources.yml"
|
|
69
|
-
sources_file.write_text(
|
|
80
|
+
sources_file.write_text(_dump_yaml(sources_doc))
|
|
70
81
|
|
|
71
82
|
# -------------------------
|
|
72
83
|
# Generate individual staging model YAMLs
|
|
@@ -97,44 +108,22 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
|
|
|
97
108
|
}
|
|
98
109
|
)
|
|
99
110
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for col in model_columns:
|
|
113
|
-
lines.append(f" - name: {col['name']}")
|
|
114
|
-
if col["description"]:
|
|
115
|
-
lines.extend(_yaml_field_lines("description", col["description"], " "))
|
|
116
|
-
if col["tests"]:
|
|
117
|
-
lines.append(" tests:")
|
|
118
|
-
for test in col["tests"]:
|
|
119
|
-
if isinstance(test, str):
|
|
120
|
-
lines.append(f" - {test}")
|
|
121
|
-
elif "accepted_values" in test:
|
|
122
|
-
lines.append(" - accepted_values:")
|
|
123
|
-
lines.append(" arguments:")
|
|
124
|
-
lines.append(" values:")
|
|
125
|
-
for value in test["accepted_values"]["values"]:
|
|
126
|
-
lines.append(f" - {value!r}")
|
|
127
|
-
else:
|
|
128
|
-
# relationships test with arguments
|
|
129
|
-
for k, v in test.items():
|
|
130
|
-
lines.append(f" - {k}:")
|
|
131
|
-
lines.append(" arguments:")
|
|
132
|
-
for fk_key, fk_val in v.items():
|
|
133
|
-
lines.append(f" {fk_key}: {fk_val}")
|
|
111
|
+
col_doc: dict[str, Any] = {"name": col.name}
|
|
112
|
+
description = getattr(col, "description", None)
|
|
113
|
+
if description:
|
|
114
|
+
col_doc["description"] = description
|
|
115
|
+
if tests:
|
|
116
|
+
col_doc["tests"] = tests
|
|
117
|
+
model_columns.append(col_doc)
|
|
118
|
+
|
|
119
|
+
model_doc = {
|
|
120
|
+
"version": 2,
|
|
121
|
+
"models": [{"name": stg_name, "columns": model_columns}],
|
|
122
|
+
}
|
|
134
123
|
|
|
135
124
|
# Write YAML to same folder as SQL model
|
|
136
125
|
yml_file = staging_path / f"{stg_name}.yml"
|
|
137
|
-
yml_file.write_text(
|
|
126
|
+
yml_file.write_text(_dump_yaml(model_doc))
|
|
138
127
|
|
|
139
128
|
# -------------------------
|
|
140
129
|
# Composite key singular tests
|
|
@@ -155,8 +144,7 @@ def _generate_seed_config(dest: Path, tables: dict) -> None:
|
|
|
155
144
|
zero-padded postcodes, ...) and would otherwise be silently loaded as
|
|
156
145
|
an integer, overflowing or dropping leading zeros.
|
|
157
146
|
"""
|
|
158
|
-
|
|
159
|
-
any_column_types = False
|
|
147
|
+
seed_entries = []
|
|
160
148
|
|
|
161
149
|
for table in tables.values():
|
|
162
150
|
column_types = {
|
|
@@ -165,19 +153,28 @@ def _generate_seed_config(dest: Path, tables: dict) -> None:
|
|
|
165
153
|
if not column_types:
|
|
166
154
|
continue
|
|
167
155
|
|
|
168
|
-
|
|
169
|
-
lines.append(f" - name: {table.name}")
|
|
170
|
-
lines.append(" config:")
|
|
171
|
-
lines.append(" column_types:")
|
|
172
|
-
for col_name, col_type in column_types.items():
|
|
173
|
-
lines.append(f" {col_name}: {col_type}")
|
|
156
|
+
seed_entries.append({"name": table.name, "config": {"column_types": column_types}})
|
|
174
157
|
|
|
175
|
-
if not
|
|
158
|
+
if not seed_entries:
|
|
176
159
|
return
|
|
177
160
|
|
|
161
|
+
seeds_doc = {"version": 2, "seeds": seed_entries}
|
|
162
|
+
|
|
178
163
|
seed_raw_path = dest / "seeds" / "raw"
|
|
179
164
|
seed_raw_path.mkdir(parents=True, exist_ok=True)
|
|
180
|
-
(seed_raw_path / "__seed_config.yml").write_text(
|
|
165
|
+
(seed_raw_path / "__seed_config.yml").write_text(_dump_yaml(seeds_doc))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _quote_sql_identifier(name: str) -> str:
|
|
169
|
+
"""ANSI double-quote a raw column identifier for use in generated SQL.
|
|
170
|
+
|
|
171
|
+
Both supported adapters (DuckDB and Postgres) accept ANSI double-quoting,
|
|
172
|
+
which is required once a DBML identifier contains a space, colon, or
|
|
173
|
+
other character that would otherwise break an unquoted `select`/`group
|
|
174
|
+
by` clause. A literal `"` inside the identifier is escaped by doubling,
|
|
175
|
+
the standard ANSI SQL convention.
|
|
176
|
+
"""
|
|
177
|
+
return '"' + name.replace('"', '""') + '"'
|
|
181
178
|
|
|
182
179
|
|
|
183
180
|
def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
|
|
@@ -193,7 +190,8 @@ def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
|
|
|
193
190
|
if len(columns) < 2:
|
|
194
191
|
continue
|
|
195
192
|
|
|
196
|
-
|
|
193
|
+
quoted_columns = [_quote_sql_identifier(c) for c in columns]
|
|
194
|
+
columns_csv = ", ".join(quoted_columns)
|
|
197
195
|
test_name = "unique_combination_" + "_".join([stg_name, *columns])
|
|
198
196
|
sql = (
|
|
199
197
|
f"select {columns_csv}, count(*) as n\n"
|
|
@@ -230,6 +228,11 @@ def generate_unit_tests(
|
|
|
230
228
|
|
|
231
229
|
sample_rows = _rows_as_native_dicts(df.head(sample_size))
|
|
232
230
|
stg_name = f"stg_{table.name}"
|
|
231
|
+
# table.name is spliced into a single-quoted Jinja string literal (the
|
|
232
|
+
# source() call is evaluated by dbt as an expression, not treated as
|
|
233
|
+
# a literal YAML string); escape any embedded single quote so an
|
|
234
|
+
# unusual DBML identifier can't break that call.
|
|
235
|
+
escaped_name = table.name.replace("'", "\\'")
|
|
233
236
|
|
|
234
237
|
unit_test = {
|
|
235
238
|
"unit_tests": [
|
|
@@ -238,7 +241,7 @@ def generate_unit_tests(
|
|
|
238
241
|
"model": stg_name,
|
|
239
242
|
"given": [
|
|
240
243
|
{
|
|
241
|
-
"input": f"source('raw', '{
|
|
244
|
+
"input": f"source('raw', '{escaped_name}')",
|
|
242
245
|
"rows": sample_rows,
|
|
243
246
|
}
|
|
244
247
|
],
|
|
@@ -16,6 +16,23 @@ from model2data.parse.dbml import TableDef
|
|
|
16
16
|
|
|
17
17
|
fake = Faker()
|
|
18
18
|
|
|
19
|
+
# Tables the most recent generate_data_from_dbml() call found stuck in an
|
|
20
|
+
# unresolved FK cycle (never reached indegree 0 during the topological
|
|
21
|
+
# sort). Exposed out-of-band, mirroring generate.faker's
|
|
22
|
+
# reset_stats()/get_unmapped_columns() pattern, so the CLI can surface a
|
|
23
|
+
# warning without changing this module's existing return signature.
|
|
24
|
+
_cycle_state: dict[str, list[str]] = {"cyclic_tables": []}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reset_cycle_state() -> None:
|
|
28
|
+
"""Clear the record of tables found in an unresolved FK cycle."""
|
|
29
|
+
_cycle_state["cyclic_tables"] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_cyclic_tables() -> list[str]:
|
|
33
|
+
"""Return table names stuck in an unresolved FK cycle by the last run."""
|
|
34
|
+
return list(_cycle_state["cyclic_tables"])
|
|
35
|
+
|
|
19
36
|
|
|
20
37
|
# ---------------------------------------------------------
|
|
21
38
|
# Public API
|
|
@@ -36,6 +53,8 @@ def generate_data_from_dbml(
|
|
|
36
53
|
random.seed(seed)
|
|
37
54
|
Faker.seed(seed)
|
|
38
55
|
|
|
56
|
+
reset_cycle_state()
|
|
57
|
+
|
|
39
58
|
# ---------------------------------------------------------
|
|
40
59
|
# Classify references
|
|
41
60
|
# ---------------------------------------------------------
|
|
@@ -259,9 +278,17 @@ def _topological_table_order(
|
|
|
259
278
|
if indegree[neighbor] == 0:
|
|
260
279
|
queue.append(neighbor)
|
|
261
280
|
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
281
|
+
# Any table not reached by the Kahn's-algorithm pass above never had its
|
|
282
|
+
# indegree reduced to 0, which (unlike a genuinely disconnected table,
|
|
283
|
+
# which starts at indegree 0 and is processed by the loop above) can only
|
|
284
|
+
# happen if it sits inside -- or depends on -- an unresolved multi-table
|
|
285
|
+
# FK cycle. Append it to the order anyway (still generate *something*
|
|
286
|
+
# rather than crash on an unusual-but-not-invalid schema), but record it
|
|
287
|
+
# so the CLI can warn the user their generated FK data may not respect
|
|
288
|
+
# every relationship.
|
|
289
|
+
leftover = sorted(name for name in tables if name not in order)
|
|
290
|
+
if leftover:
|
|
291
|
+
_cycle_state["cyclic_tables"] = leftover
|
|
292
|
+
order.extend(leftover)
|
|
266
293
|
|
|
267
294
|
return order
|