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.
- trellis_datamodel/__init__.py +8 -0
- trellis_datamodel/adapters/__init__.py +41 -0
- trellis_datamodel/adapters/base.py +147 -0
- trellis_datamodel/adapters/dbt_core.py +975 -0
- trellis_datamodel/cli.py +292 -0
- trellis_datamodel/config.py +239 -0
- trellis_datamodel/models/__init__.py +13 -0
- trellis_datamodel/models/schemas.py +28 -0
- trellis_datamodel/routes/__init__.py +11 -0
- trellis_datamodel/routes/data_model.py +221 -0
- trellis_datamodel/routes/manifest.py +110 -0
- trellis_datamodel/routes/schema.py +183 -0
- trellis_datamodel/server.py +101 -0
- trellis_datamodel/static/_app/env.js +1 -0
- trellis_datamodel/static/_app/immutable/assets/0.ByDwyx3a.css +1 -0
- trellis_datamodel/static/_app/immutable/assets/2.DLAp_5AW.css +1 -0
- trellis_datamodel/static/_app/immutable/assets/trellis_squared.CTOnsdDx.svg +127 -0
- trellis_datamodel/static/_app/immutable/chunks/8ZaN1sxc.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/BfBfOTnK.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/C3yhlRfZ.js +2 -0
- trellis_datamodel/static/_app/immutable/chunks/CK3bXPEX.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/CXDUumOQ.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/DDNfEvut.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/DUdVct7e.js +1 -0
- trellis_datamodel/static/_app/immutable/chunks/QRltG_J6.js +2 -0
- trellis_datamodel/static/_app/immutable/chunks/zXDdy2c_.js +1 -0
- trellis_datamodel/static/_app/immutable/entry/app.abCkWeAJ.js +2 -0
- trellis_datamodel/static/_app/immutable/entry/start.B7CjH6Z7.js +1 -0
- trellis_datamodel/static/_app/immutable/nodes/0.bFI_DI3G.js +1 -0
- trellis_datamodel/static/_app/immutable/nodes/1.J_r941Qf.js +1 -0
- trellis_datamodel/static/_app/immutable/nodes/2.WqbMkq6o.js +27 -0
- trellis_datamodel/static/_app/version.json +1 -0
- trellis_datamodel/static/index.html +40 -0
- trellis_datamodel/static/robots.txt +3 -0
- trellis_datamodel/static/trellis_squared.svg +127 -0
- trellis_datamodel/tests/__init__.py +2 -0
- trellis_datamodel/tests/conftest.py +132 -0
- trellis_datamodel/tests/test_cli.py +526 -0
- trellis_datamodel/tests/test_data_model.py +151 -0
- trellis_datamodel/tests/test_dbt_schema.py +892 -0
- trellis_datamodel/tests/test_manifest.py +72 -0
- trellis_datamodel/tests/test_server_static.py +44 -0
- trellis_datamodel/tests/test_yaml_handler.py +228 -0
- trellis_datamodel/utils/__init__.py +2 -0
- trellis_datamodel/utils/yaml_handler.py +365 -0
- trellis_datamodel-0.3.3.dist-info/METADATA +333 -0
- trellis_datamodel-0.3.3.dist-info/RECORD +52 -0
- trellis_datamodel-0.3.3.dist-info/WHEEL +5 -0
- trellis_datamodel-0.3.3.dist-info/entry_points.txt +2 -0
- trellis_datamodel-0.3.3.dist-info/licenses/LICENSE +661 -0
- trellis_datamodel-0.3.3.dist-info/licenses/NOTICE +6 -0
- 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
|