trellis-datamodel 0.3.3__py3-none-any.whl

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 (52) hide show
  1. trellis_datamodel/__init__.py +8 -0
  2. trellis_datamodel/adapters/__init__.py +41 -0
  3. trellis_datamodel/adapters/base.py +147 -0
  4. trellis_datamodel/adapters/dbt_core.py +975 -0
  5. trellis_datamodel/cli.py +292 -0
  6. trellis_datamodel/config.py +239 -0
  7. trellis_datamodel/models/__init__.py +13 -0
  8. trellis_datamodel/models/schemas.py +28 -0
  9. trellis_datamodel/routes/__init__.py +11 -0
  10. trellis_datamodel/routes/data_model.py +221 -0
  11. trellis_datamodel/routes/manifest.py +110 -0
  12. trellis_datamodel/routes/schema.py +183 -0
  13. trellis_datamodel/server.py +101 -0
  14. trellis_datamodel/static/_app/env.js +1 -0
  15. trellis_datamodel/static/_app/immutable/assets/0.ByDwyx3a.css +1 -0
  16. trellis_datamodel/static/_app/immutable/assets/2.DLAp_5AW.css +1 -0
  17. trellis_datamodel/static/_app/immutable/assets/trellis_squared.CTOnsdDx.svg +127 -0
  18. trellis_datamodel/static/_app/immutable/chunks/8ZaN1sxc.js +1 -0
  19. trellis_datamodel/static/_app/immutable/chunks/BfBfOTnK.js +1 -0
  20. trellis_datamodel/static/_app/immutable/chunks/C3yhlRfZ.js +2 -0
  21. trellis_datamodel/static/_app/immutable/chunks/CK3bXPEX.js +1 -0
  22. trellis_datamodel/static/_app/immutable/chunks/CXDUumOQ.js +1 -0
  23. trellis_datamodel/static/_app/immutable/chunks/DDNfEvut.js +1 -0
  24. trellis_datamodel/static/_app/immutable/chunks/DUdVct7e.js +1 -0
  25. trellis_datamodel/static/_app/immutable/chunks/QRltG_J6.js +2 -0
  26. trellis_datamodel/static/_app/immutable/chunks/zXDdy2c_.js +1 -0
  27. trellis_datamodel/static/_app/immutable/entry/app.abCkWeAJ.js +2 -0
  28. trellis_datamodel/static/_app/immutable/entry/start.B7CjH6Z7.js +1 -0
  29. trellis_datamodel/static/_app/immutable/nodes/0.bFI_DI3G.js +1 -0
  30. trellis_datamodel/static/_app/immutable/nodes/1.J_r941Qf.js +1 -0
  31. trellis_datamodel/static/_app/immutable/nodes/2.WqbMkq6o.js +27 -0
  32. trellis_datamodel/static/_app/version.json +1 -0
  33. trellis_datamodel/static/index.html +40 -0
  34. trellis_datamodel/static/robots.txt +3 -0
  35. trellis_datamodel/static/trellis_squared.svg +127 -0
  36. trellis_datamodel/tests/__init__.py +2 -0
  37. trellis_datamodel/tests/conftest.py +132 -0
  38. trellis_datamodel/tests/test_cli.py +526 -0
  39. trellis_datamodel/tests/test_data_model.py +151 -0
  40. trellis_datamodel/tests/test_dbt_schema.py +892 -0
  41. trellis_datamodel/tests/test_manifest.py +72 -0
  42. trellis_datamodel/tests/test_server_static.py +44 -0
  43. trellis_datamodel/tests/test_yaml_handler.py +228 -0
  44. trellis_datamodel/utils/__init__.py +2 -0
  45. trellis_datamodel/utils/yaml_handler.py +365 -0
  46. trellis_datamodel-0.3.3.dist-info/METADATA +333 -0
  47. trellis_datamodel-0.3.3.dist-info/RECORD +52 -0
  48. trellis_datamodel-0.3.3.dist-info/WHEEL +5 -0
  49. trellis_datamodel-0.3.3.dist-info/entry_points.txt +2 -0
  50. trellis_datamodel-0.3.3.dist-info/licenses/LICENSE +661 -0
  51. trellis_datamodel-0.3.3.dist-info/licenses/NOTICE +6 -0
  52. trellis_datamodel-0.3.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,526 @@
1
+ """Tests for CLI commands.
2
+
3
+ These tests verify CLI commands work correctly when the package is installed
4
+ (not just when running from source). This catches issues like path resolution
5
+ bugs that only manifest in installed packages.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import subprocess
11
+ import tempfile
12
+ import re
13
+ import pytest
14
+ from typer.testing import CliRunner
15
+ from pathlib import Path
16
+
17
+ runner = CliRunner()
18
+
19
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
20
+
21
+
22
+ def _strip_ansi(text: str) -> str:
23
+ """Remove ANSI escape sequences (Typer/Rich help output in CI)."""
24
+ return _ANSI_RE.sub("", text)
25
+
26
+
27
+ class TestCLIVersion:
28
+ """Test version command."""
29
+
30
+ def test_version_flag(self):
31
+ """Test --version flag shows version."""
32
+ from trellis_datamodel.cli import app
33
+
34
+ result = runner.invoke(app, ["--version"])
35
+ assert result.exit_code == 0
36
+ # Should output a version string like "0.3.3"
37
+ assert result.output.strip()
38
+ # Version should be a valid semver-ish string
39
+ parts = result.output.strip().split(".")
40
+ assert len(parts) >= 2
41
+
42
+
43
+ class TestCLIInit:
44
+ """Test init command."""
45
+
46
+ def test_init_creates_config(self):
47
+ """Test trellis init creates trellis.yml."""
48
+ from trellis_datamodel.cli import app
49
+
50
+ with tempfile.TemporaryDirectory() as tmpdir:
51
+ # Change to temp directory for the test
52
+ original_cwd = os.getcwd()
53
+ try:
54
+ os.chdir(tmpdir)
55
+ result = runner.invoke(app, ["init"])
56
+ assert result.exit_code == 0
57
+ assert "Created trellis.yml" in result.output
58
+
59
+ # Verify file was created
60
+ config_path = Path(tmpdir) / "trellis.yml"
61
+ assert config_path.exists()
62
+
63
+ # Verify content
64
+ content = config_path.read_text()
65
+ assert "framework: dbt-core" in content
66
+ assert "dbt_project_path" in content
67
+ finally:
68
+ os.chdir(original_cwd)
69
+
70
+ def test_init_fails_if_exists(self):
71
+ """Test trellis init fails if trellis.yml already exists."""
72
+ from trellis_datamodel.cli import app
73
+
74
+ with tempfile.TemporaryDirectory() as tmpdir:
75
+ original_cwd = os.getcwd()
76
+ try:
77
+ os.chdir(tmpdir)
78
+ # Create existing config
79
+ Path(tmpdir, "trellis.yml").write_text("existing: true")
80
+
81
+ result = runner.invoke(app, ["init"])
82
+ assert result.exit_code == 1
83
+ assert "already exists" in result.output
84
+ finally:
85
+ os.chdir(original_cwd)
86
+
87
+
88
+ class TestCLIGenerateCompanyData:
89
+ """Test generate-company-data command.
90
+
91
+ These tests specifically verify the path resolution logic works correctly
92
+ in various scenarios that have caused bugs in the past.
93
+
94
+ IMPORTANT: These tests must clear DATAMODEL_TEST_DIR and reload the config
95
+ module to simulate production behavior (not test mode).
96
+ """
97
+
98
+ def _create_mock_generator(self, path: Path):
99
+ """Create a minimal mock generate_data.py script."""
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(
102
+ '''"""Mock generator for testing."""
103
+ def main():
104
+ print("Mock data generation complete")
105
+ '''
106
+ )
107
+
108
+ def _get_fresh_app(self):
109
+ """Get fresh CLI app without test mode enabled."""
110
+ # Clear test environment to simulate production
111
+ old_test_dir = os.environ.pop("DATAMODEL_TEST_DIR", None)
112
+
113
+ # Force reload of config and cli modules
114
+ modules_to_remove = [
115
+ k for k in list(sys.modules.keys()) if "trellis_datamodel" in k
116
+ ]
117
+ for mod in modules_to_remove:
118
+ del sys.modules[mod]
119
+
120
+ # Import fresh
121
+ from trellis_datamodel.cli import app
122
+
123
+ return app, old_test_dir
124
+
125
+ def _restore_test_env(self, old_test_dir):
126
+ """Restore test environment after test."""
127
+ if old_test_dir:
128
+ os.environ["DATAMODEL_TEST_DIR"] = old_test_dir
129
+ # Reload modules to restore test mode
130
+ modules_to_remove = [
131
+ k for k in list(sys.modules.keys()) if "trellis_datamodel" in k
132
+ ]
133
+ for mod in modules_to_remove:
134
+ del sys.modules[mod]
135
+
136
+ def test_generate_without_config_finds_cwd_script(self):
137
+ """Test generate-company-data finds script in cwd when no config exists.
138
+
139
+ Scenario: User clones repo and runs command without any config file.
140
+ Expected: Should find ./dbt_company_dummy/generate_data.py
141
+ """
142
+ app, old_test_dir = self._get_fresh_app()
143
+ original_cwd = os.getcwd()
144
+ try:
145
+ with tempfile.TemporaryDirectory() as tmpdir:
146
+ os.chdir(tmpdir)
147
+
148
+ # Create mock generator in cwd
149
+ generator_path = Path(tmpdir) / "dbt_company_dummy" / "generate_data.py"
150
+ self._create_mock_generator(generator_path)
151
+
152
+ result = runner.invoke(app, ["generate-company-data"])
153
+ assert result.exit_code == 0, f"Command failed: {result.output}"
154
+ assert "Mock data generation complete" in result.output
155
+ finally:
156
+ os.chdir(original_cwd)
157
+ self._restore_test_env(old_test_dir)
158
+
159
+ def test_generate_with_config_but_no_dummy_path_configured(self):
160
+ """Test generate-company-data works when config exists but dbt_company_dummy_path is not set.
161
+
162
+ Scenario: User runs 'trellis init' then 'trellis generate-company-data'.
163
+ The config file exists but doesn't have dbt_company_dummy_path configured.
164
+ Expected: Should fall back to ./dbt_company_dummy/generate_data.py in cwd.
165
+
166
+ This is the exact bug that was fixed in v0.3.3 - the config loader was
167
+ setting a default path that didn't exist instead of letting CLI use fallback logic.
168
+ """
169
+ app, old_test_dir = self._get_fresh_app()
170
+ original_cwd = os.getcwd()
171
+ try:
172
+ with tempfile.TemporaryDirectory() as tmpdir:
173
+ os.chdir(tmpdir)
174
+
175
+ # Create config file WITHOUT dbt_company_dummy_path
176
+ config_path = Path(tmpdir) / "trellis.yml"
177
+ config_path.write_text(
178
+ """\
179
+ framework: dbt-core
180
+ dbt_project_path: "."
181
+ dbt_manifest_path: "target/manifest.json"
182
+ data_model_file: "data_model.yml"
183
+ """
184
+ )
185
+
186
+ # Create mock generator in cwd
187
+ generator_path = Path(tmpdir) / "dbt_company_dummy" / "generate_data.py"
188
+ self._create_mock_generator(generator_path)
189
+
190
+ result = runner.invoke(app, ["generate-company-data"])
191
+ assert result.exit_code == 0, f"Command failed: {result.output}"
192
+ assert "Mock data generation complete" in result.output
193
+ finally:
194
+ os.chdir(original_cwd)
195
+ self._restore_test_env(old_test_dir)
196
+
197
+ def test_generate_with_explicit_dummy_path_configured(self):
198
+ """Test generate-company-data uses explicit dbt_company_dummy_path from config."""
199
+ app, old_test_dir = self._get_fresh_app()
200
+ original_cwd = os.getcwd()
201
+ try:
202
+ with tempfile.TemporaryDirectory() as tmpdir:
203
+ os.chdir(tmpdir)
204
+
205
+ # Create custom directory for dummy data
206
+ custom_dummy_dir = Path(tmpdir) / "my_custom_dummy"
207
+ generator_path = custom_dummy_dir / "generate_data.py"
208
+ self._create_mock_generator(generator_path)
209
+
210
+ # Create config with explicit dbt_company_dummy_path
211
+ config_path = Path(tmpdir) / "trellis.yml"
212
+ config_path.write_text(
213
+ f"""\
214
+ framework: dbt-core
215
+ dbt_project_path: "."
216
+ dbt_company_dummy_path: "{custom_dummy_dir}"
217
+ """
218
+ )
219
+
220
+ result = runner.invoke(app, ["generate-company-data"])
221
+ assert result.exit_code == 0, f"Command failed: {result.output}"
222
+ assert "Mock data generation complete" in result.output
223
+ finally:
224
+ os.chdir(original_cwd)
225
+ self._restore_test_env(old_test_dir)
226
+
227
+ def test_generate_with_relative_dummy_path_configured(self):
228
+ """Test generate-company-data resolves relative dbt_company_dummy_path."""
229
+ app, old_test_dir = self._get_fresh_app()
230
+ original_cwd = os.getcwd()
231
+ try:
232
+ with tempfile.TemporaryDirectory() as tmpdir:
233
+ os.chdir(tmpdir)
234
+
235
+ # Create custom directory for dummy data
236
+ custom_dummy_dir = Path(tmpdir) / "subdir" / "dummy_data"
237
+ generator_path = custom_dummy_dir / "generate_data.py"
238
+ self._create_mock_generator(generator_path)
239
+
240
+ # Create config with relative dbt_company_dummy_path
241
+ config_path = Path(tmpdir) / "trellis.yml"
242
+ config_path.write_text(
243
+ """\
244
+ framework: dbt-core
245
+ dbt_project_path: "."
246
+ dbt_company_dummy_path: "subdir/dummy_data"
247
+ """
248
+ )
249
+
250
+ result = runner.invoke(app, ["generate-company-data"])
251
+ assert result.exit_code == 0, f"Command failed: {result.output}"
252
+ assert "Mock data generation complete" in result.output
253
+ finally:
254
+ os.chdir(original_cwd)
255
+ self._restore_test_env(old_test_dir)
256
+
257
+ def test_generate_fails_gracefully_when_script_missing(self):
258
+ """Test generate-company-data shows helpful error when script not found."""
259
+ app, old_test_dir = self._get_fresh_app()
260
+ original_cwd = os.getcwd()
261
+ try:
262
+ with tempfile.TemporaryDirectory() as tmpdir:
263
+ os.chdir(tmpdir)
264
+
265
+ # Create a config file to prevent fallback to repo root
266
+ config_path = Path(tmpdir) / "trellis.yml"
267
+ config_path.write_text(
268
+ """\
269
+ framework: dbt-core
270
+ dbt_project_path: "."
271
+ dbt_company_dummy_path: "nonexistent_dummy"
272
+ """
273
+ )
274
+
275
+ # No generator script exists at configured path
276
+ result = runner.invoke(app, ["generate-company-data"])
277
+ assert result.exit_code == 1
278
+ assert "Generator script not found" in result.output
279
+ assert "nonexistent_dummy" in result.output
280
+ finally:
281
+ os.chdir(original_cwd)
282
+ self._restore_test_env(old_test_dir)
283
+
284
+
285
+ class TestCLIRun:
286
+ """Test run/serve commands."""
287
+
288
+ def test_run_fails_without_config(self):
289
+ """Test trellis run fails gracefully without config file."""
290
+ original_cwd = os.getcwd()
291
+ # Clear test environment variable to simulate production
292
+ old_test_dir = os.environ.pop("DATAMODEL_TEST_DIR", None)
293
+
294
+ # Force reload of config and cli modules
295
+ modules_to_remove = [
296
+ k for k in list(sys.modules.keys()) if "trellis_datamodel" in k
297
+ ]
298
+ for mod in modules_to_remove:
299
+ del sys.modules[mod]
300
+
301
+ from trellis_datamodel.cli import app
302
+
303
+ try:
304
+ with tempfile.TemporaryDirectory() as tmpdir:
305
+ os.chdir(tmpdir)
306
+ result = runner.invoke(app, ["run"])
307
+ assert result.exit_code == 1
308
+ assert "No config file found" in result.output
309
+ assert "trellis init" in result.output
310
+ finally:
311
+ os.chdir(original_cwd)
312
+ if old_test_dir:
313
+ os.environ["DATAMODEL_TEST_DIR"] = old_test_dir
314
+ # Reload modules to restore test mode
315
+ modules_to_remove = [
316
+ k for k in list(sys.modules.keys()) if "trellis_datamodel" in k
317
+ ]
318
+ for mod in modules_to_remove:
319
+ del sys.modules[mod]
320
+
321
+
322
+ class TestCLIHelp:
323
+ """Test help output."""
324
+
325
+ def test_help_shows_commands(self):
326
+ """Test --help shows available commands."""
327
+ from trellis_datamodel.cli import app
328
+
329
+ # Disable rich/ANSI output so assertions work in CI ("dumb" terminals).
330
+ result = runner.invoke(app, ["--help"], color=False)
331
+ assert result.exit_code == 0
332
+ out = _strip_ansi(result.output)
333
+ assert "run" in out
334
+ assert "init" in out
335
+ assert "generate-company-data" in out
336
+
337
+ def test_subcommand_help(self):
338
+ """Test subcommand --help works."""
339
+ from trellis_datamodel.cli import app
340
+
341
+ # Disable rich/ANSI output so "--port" isn't split by escape codes.
342
+ result = runner.invoke(app, ["run", "--help"], color=False)
343
+ assert result.exit_code == 0
344
+ out = _strip_ansi(result.output)
345
+ assert "--port" in out
346
+ assert "--config" in out
347
+
348
+
349
+ class TestCLIInstalledPackage:
350
+ """Test CLI commands when package is installed (not from source).
351
+
352
+ These tests simulate real-world usage by:
353
+ 1. Building the package as a wheel
354
+ 2. Creating an isolated virtual environment
355
+ 3. Installing the wheel in that venv
356
+ 4. Running CLI commands from a completely different directory
357
+ 5. Verifying path resolution works correctly
358
+
359
+ This catches bugs that only manifest when:
360
+ - __file__ points to site-packages, not source repo
361
+ - No access to source repo's dbt_company_dummy directory
362
+ - Package is installed via pip/uv, not editable mode
363
+ """
364
+
365
+ def _build_package(self, repo_root: Path) -> Path:
366
+ """Build the package and return path to wheel."""
367
+ # Try different build methods
368
+ build_commands = [
369
+ ["uv", "build"], # Preferred: uv build
370
+ ["python", "-m", "build", "--wheel"], # Fallback: python -m build
371
+ ]
372
+
373
+ dist_dir = repo_root / "dist"
374
+ dist_dir.mkdir(exist_ok=True)
375
+
376
+ # Check if wheel already exists (from previous test run or manual build)
377
+ wheels = list(dist_dir.glob("*.whl"))
378
+ if wheels:
379
+ return wheels[0]
380
+
381
+ # Try to build
382
+ for cmd in build_commands:
383
+ try:
384
+ result = subprocess.run(
385
+ cmd,
386
+ cwd=repo_root,
387
+ capture_output=True,
388
+ text=True,
389
+ )
390
+
391
+ if result.returncode == 0:
392
+ wheels = list(dist_dir.glob("*.whl"))
393
+ if wheels:
394
+ return wheels[0]
395
+ except FileNotFoundError:
396
+ continue
397
+
398
+ pytest.skip(
399
+ "Could not build package - no build tool available (uv or python -m build)"
400
+ )
401
+
402
+ def _create_isolated_venv(self, venv_dir: Path):
403
+ """Create an isolated virtual environment."""
404
+ subprocess.run(
405
+ [sys.executable, "-m", "venv", str(venv_dir)],
406
+ check=True,
407
+ capture_output=True,
408
+ )
409
+
410
+ def _install_package_in_venv(self, venv_dir: Path, wheel_path: Path):
411
+ """Install the wheel in the virtual environment."""
412
+ pip = venv_dir / "bin" / "pip"
413
+ if not pip.exists():
414
+ pip = venv_dir / "Scripts" / "pip.exe" # Windows
415
+
416
+ subprocess.run(
417
+ [str(pip), "install", str(wheel_path)],
418
+ check=True,
419
+ capture_output=True,
420
+ )
421
+
422
+ def _get_venv_trellis_command(self, venv_dir: Path) -> Path:
423
+ """Get path to trellis command in venv."""
424
+ trellis = venv_dir / "bin" / "trellis"
425
+ if not trellis.exists():
426
+ trellis = venv_dir / "Scripts" / "trellis.exe" # Windows
427
+ return trellis
428
+
429
+ def test_generate_company_data_with_installed_package(self):
430
+ """Test generate-company-data works when package is installed (not editable).
431
+
432
+ This simulates the exact scenario from the bug report:
433
+ - Package installed via pip (not editable)
434
+ - User runs 'trellis init' in their project
435
+ - User runs 'trellis generate-company-data'
436
+ - No dbt_company_dummy_path configured in trellis.yml
437
+ - dbt_company_dummy exists in user's project directory
438
+
439
+ This test ensures the fix works in real-world installations.
440
+ """
441
+ repo_root = Path(__file__).parent.parent.parent
442
+ original_cwd = os.getcwd()
443
+
444
+ with tempfile.TemporaryDirectory() as tmpdir:
445
+ tmp_path = Path(tmpdir)
446
+
447
+ # 1. Build the package (skip if build tools unavailable)
448
+ try:
449
+ wheel_path = self._build_package(repo_root)
450
+ except Exception as e:
451
+ pytest.skip(f"Could not build package: {e}")
452
+
453
+ # 2. Create isolated venv
454
+ venv_dir = tmp_path / "venv"
455
+ try:
456
+ self._create_isolated_venv(venv_dir)
457
+ except Exception as e:
458
+ pytest.skip(f"Could not create venv: {e}")
459
+
460
+ # 3. Install package in venv
461
+ try:
462
+ self._install_package_in_venv(venv_dir, wheel_path)
463
+ except Exception as e:
464
+ pytest.skip(f"Could not install package: {e}")
465
+
466
+ # 4. Create a completely separate "user project" directory
467
+ # (simulating a different repo/system where user installed the package)
468
+ user_project_dir = tmp_path / "my_project"
469
+ user_project_dir.mkdir()
470
+
471
+ # 5. Create mock generator in user's project (simulating cloned dbt_company_dummy)
472
+ generator_path = user_project_dir / "dbt_company_dummy" / "generate_data.py"
473
+ generator_path.parent.mkdir(parents=True, exist_ok=True)
474
+ generator_path.write_text(
475
+ '''"""Mock generator for testing."""
476
+ def main():
477
+ print("Mock data generation complete")
478
+ '''
479
+ )
480
+
481
+ # 6. Create trellis.yml WITHOUT dbt_company_dummy_path (the bug scenario)
482
+ config_path = user_project_dir / "trellis.yml"
483
+ config_path.write_text(
484
+ """\
485
+ framework: dbt-core
486
+ dbt_project_path: "."
487
+ dbt_manifest_path: "target/manifest.json"
488
+ data_model_file: "data_model.yml"
489
+ """
490
+ )
491
+
492
+ # 7. Run trellis command from user's project directory
493
+ # This simulates real-world usage where __file__ points to site-packages
494
+ trellis_cmd = self._get_venv_trellis_command(venv_dir)
495
+ if not trellis_cmd.exists():
496
+ pytest.skip(f"trellis command not found at {trellis_cmd}")
497
+
498
+ os.chdir(user_project_dir)
499
+
500
+ # Clear any test environment variables that might interfere
501
+ test_env = {
502
+ k: v for k, v in os.environ.items() if not k.startswith("DATAMODEL_")
503
+ }
504
+ test_env["PYTHONUNBUFFERED"] = "1"
505
+
506
+ try:
507
+ result = subprocess.run(
508
+ [str(trellis_cmd), "generate-company-data"],
509
+ capture_output=True,
510
+ text=True,
511
+ cwd=str(user_project_dir),
512
+ env=test_env, # Use clean environment without test vars
513
+ )
514
+
515
+ assert result.returncode == 0, (
516
+ f"Command failed with exit code {result.returncode}\n"
517
+ f"STDOUT: {result.stdout}\n"
518
+ f"STDERR: {result.stderr}"
519
+ )
520
+ assert "Mock data generation complete" in result.stdout, (
521
+ f"Expected 'Mock data generation complete' in output\n"
522
+ f"STDOUT: {result.stdout}\n"
523
+ f"STDERR: {result.stderr}"
524
+ )
525
+ finally:
526
+ os.chdir(original_cwd)
@@ -0,0 +1,151 @@
1
+ """Tests for data model API endpoints."""
2
+
3
+ import os
4
+ import yaml
5
+ import pytest
6
+
7
+
8
+ class TestGetDataModel:
9
+ """Tests for GET /api/data-model endpoint."""
10
+
11
+ def test_returns_empty_model_when_file_missing(self, test_client):
12
+ response = test_client.get("/api/data-model")
13
+ assert response.status_code == 200
14
+ data = response.json()
15
+ assert data["version"] == 0.1
16
+ assert data["entities"] == []
17
+ assert data["relationships"] == []
18
+
19
+ def test_returns_existing_model(
20
+ self, test_client, temp_data_model_path, temp_canvas_layout_path
21
+ ):
22
+ # Create a data model file (model-only)
23
+ model_data = {
24
+ "version": 0.1,
25
+ "entities": [{"id": "users", "label": "Users"}],
26
+ "relationships": [
27
+ {"source": "orders", "target": "users", "type": "one_to_many"}
28
+ ],
29
+ }
30
+ with open(temp_data_model_path, "w") as f:
31
+ yaml.dump(model_data, f)
32
+
33
+ # Create a canvas layout file (layout-only)
34
+ layout_data = {
35
+ "version": 0.1,
36
+ "entities": {
37
+ "users": {
38
+ "position": {"x": 0, "y": 0},
39
+ "width": 280,
40
+ "collapsed": False,
41
+ }
42
+ },
43
+ "relationships": {"orders-users-0": {"label_dx": 10, "label_dy": 20}},
44
+ }
45
+ with open(temp_canvas_layout_path, "w") as f:
46
+ yaml.dump(layout_data, f)
47
+
48
+ response = test_client.get("/api/data-model")
49
+ assert response.status_code == 200
50
+ data = response.json()
51
+ assert len(data["entities"]) == 1
52
+ assert data["entities"][0]["id"] == "users"
53
+ # Verify layout is merged
54
+ assert data["entities"][0]["position"] == {"x": 0, "y": 0}
55
+ assert data["entities"][0]["width"] == 280
56
+ assert data["relationships"][0]["label_dx"] == 10
57
+ assert data["relationships"][0]["label_dy"] == 20
58
+
59
+ def test_handles_file_with_missing_keys(self, test_client, temp_data_model_path):
60
+ # Create a minimal data model file
61
+ with open(temp_data_model_path, "w") as f:
62
+ yaml.dump({"version": 0.1}, f)
63
+
64
+ response = test_client.get("/api/data-model")
65
+ assert response.status_code == 200
66
+ data = response.json()
67
+ assert data["entities"] == []
68
+ assert data["relationships"] == []
69
+
70
+
71
+ class TestSaveDataModel:
72
+ """Tests for POST /api/data-model endpoint."""
73
+
74
+ def test_saves_new_model(
75
+ self, test_client, temp_data_model_path, temp_canvas_layout_path
76
+ ):
77
+ model_data = {
78
+ "version": 0.1,
79
+ "entities": [
80
+ {
81
+ "id": "users",
82
+ "label": "Users",
83
+ "position": {"x": 100, "y": 200},
84
+ "width": 300,
85
+ "collapsed": False,
86
+ }
87
+ ],
88
+ "relationships": [],
89
+ }
90
+ response = test_client.post("/api/data-model", json=model_data)
91
+ assert response.status_code == 200
92
+ assert response.json()["status"] == "success"
93
+
94
+ # Verify model file was written (without visual properties)
95
+ assert os.path.exists(temp_data_model_path)
96
+ with open(temp_data_model_path, "r") as f:
97
+ saved = yaml.safe_load(f)
98
+ assert saved["entities"][0]["id"] == "users"
99
+ assert "position" not in saved["entities"][0]
100
+ assert "width" not in saved["entities"][0]
101
+
102
+ # Verify layout file was written (with visual properties only)
103
+ assert os.path.exists(temp_canvas_layout_path)
104
+ with open(temp_canvas_layout_path, "r") as f:
105
+ layout = yaml.safe_load(f)
106
+ assert "users" in layout["entities"]
107
+ assert layout["entities"]["users"]["position"] == {"x": 100, "y": 200}
108
+ assert layout["entities"]["users"]["width"] == 300
109
+
110
+ def test_overwrites_existing_model(
111
+ self, test_client, temp_data_model_path, temp_canvas_layout_path
112
+ ):
113
+ # Create initial model and layout
114
+ with open(temp_data_model_path, "w") as f:
115
+ yaml.dump(
116
+ {"version": 0.1, "entities": [{"id": "old"}], "relationships": []}, f
117
+ )
118
+ with open(temp_canvas_layout_path, "w") as f:
119
+ yaml.dump(
120
+ {
121
+ "version": 0.1,
122
+ "entities": {"old": {"position": {"x": 50, "y": 50}}},
123
+ "relationships": {},
124
+ },
125
+ f,
126
+ )
127
+
128
+ # Overwrite with new model
129
+ model_data = {
130
+ "version": 0.1,
131
+ "entities": [{"id": "new", "label": "New", "position": {"x": 0, "y": 0}}],
132
+ "relationships": [],
133
+ }
134
+ response = test_client.post("/api/data-model", json=model_data)
135
+ assert response.status_code == 200
136
+
137
+ # Verify old entity is removed from both files
138
+ with open(temp_data_model_path, "r") as f:
139
+ saved = yaml.safe_load(f)
140
+ assert len(saved["entities"]) == 1
141
+ assert saved["entities"][0]["id"] == "new"
142
+
143
+ with open(temp_canvas_layout_path, "r") as f:
144
+ layout = yaml.safe_load(f)
145
+ assert "old" not in layout["entities"]
146
+ assert "new" in layout["entities"]
147
+
148
+ def test_validates_required_fields(self, test_client):
149
+ # Missing required fields should fail validation
150
+ response = test_client.post("/api/data-model", json={})
151
+ assert response.status_code == 422 # Pydantic validation error