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.
Files changed (35) hide show
  1. {model2data-0.4.3 → model2data-0.5.0}/PKG-INFO +11 -5
  2. {model2data-0.4.3 → model2data-0.5.0}/README.md +7 -1
  3. {model2data-0.4.3 → model2data-0.5.0}/model2data/cli.py +13 -2
  4. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/project.py +5 -1
  5. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/tests.py +64 -61
  6. {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/core.py +31 -4
  7. {model2data-0.4.3 → model2data-0.5.0}/model2data/parse/dbml.py +208 -39
  8. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/PKG-INFO +11 -5
  9. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/requires.txt +3 -3
  10. {model2data-0.4.3 → model2data-0.5.0}/pyproject.toml +4 -4
  11. {model2data-0.4.3 → model2data-0.5.0}/tests/test_cli.py +68 -0
  12. {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbml_parser.py +301 -0
  13. {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_project.py +17 -0
  14. {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_tests.py +93 -11
  15. {model2data-0.4.3 → model2data-0.5.0}/tests/test_generation.py +103 -1
  16. {model2data-0.4.3 → model2data-0.5.0}/LICENSE +0 -0
  17. {model2data-0.4.3 → model2data-0.5.0}/model2data/__init__.py +0 -0
  18. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/__init__.py +0 -0
  19. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  20. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  21. {model2data-0.4.3 → model2data-0.5.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  22. {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/__init__.py +0 -0
  23. {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/faker.py +0 -0
  24. {model2data-0.4.3 → model2data-0.5.0}/model2data/generate/relationships.py +0 -0
  25. {model2data-0.4.3 → model2data-0.5.0}/model2data/parse/__init__.py +0 -0
  26. {model2data-0.4.3 → model2data-0.5.0}/model2data/utils.py +0 -0
  27. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/SOURCES.txt +0 -0
  28. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/dependency_links.txt +0 -0
  29. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/entry_points.txt +0 -0
  30. {model2data-0.4.3 → model2data-0.5.0}/model2data.egg-info/top_level.txt +0 -0
  31. {model2data-0.4.3 → model2data-0.5.0}/setup.cfg +0 -0
  32. {model2data-0.4.3 → model2data-0.5.0}/tests/test_coverage_gaps.py +0 -0
  33. {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_integration.py +0 -0
  34. {model2data-0.4.3 → model2data-0.5.0}/tests/test_dbt_naming.py +0 -0
  35. {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.4.3
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.0
9
- Requires-Dist: dbt-duckdb>=1.5.0
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.5.0; extra == "postgres"
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 **dbt-core >= 1.8**. Since this project only requires `dbt-core>=1.5.0` by default, unit test generation is opt-in leave the flag off if your dbt-core version is older.
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 **dbt-core >= 1.8**. Since this project only requires `dbt-core>=1.5.0` by default, unit test generation is opt-in leave the flag off if your dbt-core version is older.
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', '{table_name}') }}}}
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 _yaml_field_lines(key: str, value: str, indent: str) -> list[str]:
14
- """Render `key: value` as safely-escaped YAML lines at the given indent."""
15
- dumped = yaml.safe_dump({key: value}, default_flow_style=False, sort_keys=False)
16
- return [f"{indent}{line}" for line in dumped.strip("\n").splitlines()]
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
- sources_lines = ["version: 2", "", "sources:"]
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
- sources_lines.append(f" - name: {seed_name}")
66
- sources_lines.extend(_yaml_field_lines("description", table_desc, " "))
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("\n".join(sources_lines))
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
- model_columns.append(
101
- {
102
- "name": col.name,
103
- "description": getattr(col, "description", None),
104
- "tests": tests if tests else None,
105
- }
106
- )
107
-
108
- # Render model YAML
109
- lines = ["version: 2", "", "models:"]
110
- lines.append(f" - name: {stg_name}")
111
- lines.append(" columns:")
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("\n".join(lines))
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
- lines = ["version: 2", "", "seeds:"]
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
- any_column_types = True
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 any_column_types:
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("\n".join(lines))
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
- columns_csv = ", ".join(columns)
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', '{table.name}')",
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
- # Safety net for disconnected tables
263
- for name in tables.keys():
264
- if name not in order:
265
- order.append(name)
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