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,72 @@
|
|
|
1
|
+
"""Tests for manifest API endpoints."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestGetConfigStatus:
|
|
8
|
+
"""Tests for GET /api/config-status endpoint."""
|
|
9
|
+
|
|
10
|
+
def test_returns_status(self, test_client, mock_manifest):
|
|
11
|
+
response = test_client.get("/api/config-status")
|
|
12
|
+
assert response.status_code == 200
|
|
13
|
+
data = response.json()
|
|
14
|
+
|
|
15
|
+
assert data["config_present"] is True
|
|
16
|
+
assert data["manifest_exists"] is True
|
|
17
|
+
assert "dbt_project_path" in data
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestGetManifest:
|
|
21
|
+
"""Tests for GET /api/manifest endpoint."""
|
|
22
|
+
|
|
23
|
+
def test_returns_models_from_manifest(self, test_client):
|
|
24
|
+
response = test_client.get("/api/manifest")
|
|
25
|
+
assert response.status_code == 200
|
|
26
|
+
data = response.json()
|
|
27
|
+
|
|
28
|
+
assert "models" in data
|
|
29
|
+
models = data["models"]
|
|
30
|
+
assert len(models) == 2
|
|
31
|
+
|
|
32
|
+
# Models should be sorted by name
|
|
33
|
+
assert models[0]["name"] == "orders"
|
|
34
|
+
assert models[1]["name"] == "users"
|
|
35
|
+
|
|
36
|
+
def test_model_fields(self, test_client):
|
|
37
|
+
response = test_client.get("/api/manifest")
|
|
38
|
+
data = response.json()
|
|
39
|
+
|
|
40
|
+
users_model = next(m for m in data["models"] if m["name"] == "users")
|
|
41
|
+
assert users_model["unique_id"] == "model.project.users"
|
|
42
|
+
assert users_model["schema"] == "public"
|
|
43
|
+
assert users_model["description"] == "User table"
|
|
44
|
+
assert users_model["materialization"] == "table"
|
|
45
|
+
assert users_model["tags"] == ["core"]
|
|
46
|
+
|
|
47
|
+
def test_filters_by_model_path(self, test_client, temp_dir, mock_manifest):
|
|
48
|
+
# Update manifest to have models in different paths
|
|
49
|
+
with open(mock_manifest, "r") as f:
|
|
50
|
+
manifest = json.load(f)
|
|
51
|
+
|
|
52
|
+
manifest["nodes"]["model.project.staging"] = {
|
|
53
|
+
"unique_id": "model.project.staging",
|
|
54
|
+
"resource_type": "model",
|
|
55
|
+
"name": "stg_users",
|
|
56
|
+
"schema": "staging",
|
|
57
|
+
"original_file_path": "models/1_staging/stg_users.sql",
|
|
58
|
+
"columns": {},
|
|
59
|
+
"config": {},
|
|
60
|
+
"tags": [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
with open(mock_manifest, "w") as f:
|
|
64
|
+
json.dump(manifest, f)
|
|
65
|
+
|
|
66
|
+
# DBT_MODEL_PATHS is set to ["3_core"] so staging model should be filtered out
|
|
67
|
+
response = test_client.get("/api/manifest")
|
|
68
|
+
data = response.json()
|
|
69
|
+
|
|
70
|
+
model_names = [m["name"] for m in data["models"]]
|
|
71
|
+
assert "stg_users" not in model_names
|
|
72
|
+
assert "users" in model_names
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def temp_frontend_build(tmp_path, monkeypatch):
|
|
11
|
+
"""
|
|
12
|
+
Create a minimal frontend build directory and point config to it.
|
|
13
|
+
"""
|
|
14
|
+
build_dir: Path = tmp_path / "frontend" / "build"
|
|
15
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
index_file = build_dir / "index.html"
|
|
17
|
+
index_file.write_text("<html><body>Hello Test Build</body></html>")
|
|
18
|
+
|
|
19
|
+
# Force test-mode paths and override frontend build dir
|
|
20
|
+
monkeypatch.setenv("DATAMODEL_TEST_DIR", str(tmp_path))
|
|
21
|
+
monkeypatch.setenv("DATAMODEL_FRONTEND_BUILD_DIR", str(build_dir))
|
|
22
|
+
|
|
23
|
+
return build_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_serves_frontend_build_when_present(temp_frontend_build):
|
|
27
|
+
"""
|
|
28
|
+
Ensure the server serves index.html from the configured frontend build dir.
|
|
29
|
+
"""
|
|
30
|
+
# Reload config/server to pick up env overrides
|
|
31
|
+
import trellis_datamodel.config as cfg
|
|
32
|
+
import trellis_datamodel.server as srv
|
|
33
|
+
|
|
34
|
+
importlib.reload(cfg)
|
|
35
|
+
cfg.load_config(None)
|
|
36
|
+
importlib.reload(srv)
|
|
37
|
+
|
|
38
|
+
app = srv.create_app()
|
|
39
|
+
client = TestClient(app)
|
|
40
|
+
|
|
41
|
+
resp = client.get("/")
|
|
42
|
+
assert resp.status_code == 200
|
|
43
|
+
assert b"Hello Test Build" in resp.content
|
|
44
|
+
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Tests for YamlHandler utility class."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pytest
|
|
5
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
6
|
+
|
|
7
|
+
from trellis_datamodel.utils.yaml_handler import YamlHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestYamlHandlerFileOperations:
|
|
11
|
+
"""Test file I/O operations."""
|
|
12
|
+
|
|
13
|
+
def test_load_nonexistent_file(self, temp_dir):
|
|
14
|
+
handler = YamlHandler()
|
|
15
|
+
result = handler.load_file(os.path.join(temp_dir, "missing.yml"))
|
|
16
|
+
assert result is None
|
|
17
|
+
|
|
18
|
+
def test_load_and_save_file(self, temp_dir):
|
|
19
|
+
handler = YamlHandler()
|
|
20
|
+
file_path = os.path.join(temp_dir, "test.yml")
|
|
21
|
+
|
|
22
|
+
# Save data
|
|
23
|
+
data = {"version": 2, "models": [{"name": "test_model"}]}
|
|
24
|
+
handler.save_file(file_path, data)
|
|
25
|
+
|
|
26
|
+
# Load data back
|
|
27
|
+
loaded = handler.load_file(file_path)
|
|
28
|
+
assert loaded is not None
|
|
29
|
+
assert loaded["version"] == 2
|
|
30
|
+
assert loaded["models"][0]["name"] == "test_model"
|
|
31
|
+
|
|
32
|
+
def test_save_creates_directories(self, temp_dir):
|
|
33
|
+
handler = YamlHandler()
|
|
34
|
+
nested_path = os.path.join(temp_dir, "nested", "deep", "test.yml")
|
|
35
|
+
data = {"version": 2}
|
|
36
|
+
handler.save_file(nested_path, data)
|
|
37
|
+
assert os.path.exists(nested_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestYamlHandlerModelOperations:
|
|
41
|
+
"""Test model-level operations."""
|
|
42
|
+
|
|
43
|
+
def test_find_model_returns_none_for_empty_data(self):
|
|
44
|
+
handler = YamlHandler()
|
|
45
|
+
assert handler.find_model({}, "test") is None
|
|
46
|
+
assert handler.find_model({"models": []}, "test") is None
|
|
47
|
+
|
|
48
|
+
def test_find_model_returns_match(self):
|
|
49
|
+
handler = YamlHandler()
|
|
50
|
+
data = {"models": [{"name": "users"}, {"name": "orders"}]}
|
|
51
|
+
result = handler.find_model(data, "users")
|
|
52
|
+
assert result is not None
|
|
53
|
+
assert result["name"] == "users"
|
|
54
|
+
|
|
55
|
+
def test_ensure_model_creates_new(self):
|
|
56
|
+
handler = YamlHandler()
|
|
57
|
+
data = {}
|
|
58
|
+
model = handler.ensure_model(data, "new_model")
|
|
59
|
+
|
|
60
|
+
assert "version" in data
|
|
61
|
+
assert data["version"] == 2
|
|
62
|
+
assert model["name"] == "new_model"
|
|
63
|
+
# Tags are not auto-created; update_model_tags handles them when needed
|
|
64
|
+
assert "tags" not in model
|
|
65
|
+
|
|
66
|
+
def test_ensure_model_returns_existing(self):
|
|
67
|
+
handler = YamlHandler()
|
|
68
|
+
existing = CommentedMap({"name": "existing", "description": "test"})
|
|
69
|
+
data = {"version": 2, "models": CommentedSeq([existing])}
|
|
70
|
+
|
|
71
|
+
model = handler.ensure_model(data, "existing")
|
|
72
|
+
assert model["description"] == "test"
|
|
73
|
+
assert len(data["models"]) == 1
|
|
74
|
+
|
|
75
|
+
def test_update_model_description(self):
|
|
76
|
+
handler = YamlHandler()
|
|
77
|
+
model = CommentedMap({"name": "test"})
|
|
78
|
+
handler.update_model_description(model, "New description")
|
|
79
|
+
assert model["description"] == "New description"
|
|
80
|
+
|
|
81
|
+
def test_update_model_description_skips_none(self):
|
|
82
|
+
handler = YamlHandler()
|
|
83
|
+
model = CommentedMap({"name": "test"})
|
|
84
|
+
handler.update_model_description(model, None)
|
|
85
|
+
assert "description" not in model
|
|
86
|
+
|
|
87
|
+
def test_update_model_tags(self):
|
|
88
|
+
handler = YamlHandler()
|
|
89
|
+
model = CommentedMap({"name": "test", "tags": []})
|
|
90
|
+
handler.update_model_tags(model, ["core", "pii"])
|
|
91
|
+
assert model["tags"] == ["core", "pii"]
|
|
92
|
+
|
|
93
|
+
def test_update_model_tags_uses_config_as_default(self):
|
|
94
|
+
handler = YamlHandler()
|
|
95
|
+
model = CommentedMap({"name": "test"})
|
|
96
|
+
handler.update_model_tags(model, ["core", "pii"])
|
|
97
|
+
# Should use config.tags as default when no tags exist
|
|
98
|
+
assert "tags" not in model
|
|
99
|
+
assert model["config"]["tags"] == ["core", "pii"]
|
|
100
|
+
|
|
101
|
+
def test_update_model_tags_preserves_config_location(self):
|
|
102
|
+
handler = YamlHandler()
|
|
103
|
+
model = CommentedMap({"name": "test", "config": {"tags": ["old"]}})
|
|
104
|
+
handler.update_model_tags(model, ["new"])
|
|
105
|
+
# Should update in config.tags (original location)
|
|
106
|
+
assert model["config"]["tags"] == ["new"]
|
|
107
|
+
assert "tags" not in model
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestYamlHandlerColumnOperations:
|
|
111
|
+
"""Test column-level operations."""
|
|
112
|
+
|
|
113
|
+
def test_find_column_returns_none_for_missing(self):
|
|
114
|
+
handler = YamlHandler()
|
|
115
|
+
model = CommentedMap({"name": "test", "columns": []})
|
|
116
|
+
assert handler.find_column(model, "missing") is None
|
|
117
|
+
|
|
118
|
+
def test_find_column_returns_match(self):
|
|
119
|
+
handler = YamlHandler()
|
|
120
|
+
model = CommentedMap(
|
|
121
|
+
{"name": "test", "columns": [{"name": "id"}, {"name": "name"}]}
|
|
122
|
+
)
|
|
123
|
+
result = handler.find_column(model, "id")
|
|
124
|
+
assert result is not None
|
|
125
|
+
assert result["name"] == "id"
|
|
126
|
+
|
|
127
|
+
def test_ensure_column_creates_new(self):
|
|
128
|
+
handler = YamlHandler()
|
|
129
|
+
model = CommentedMap({"name": "test"})
|
|
130
|
+
col = handler.ensure_column(model, "new_col")
|
|
131
|
+
|
|
132
|
+
assert "columns" in model
|
|
133
|
+
assert col["name"] == "new_col"
|
|
134
|
+
|
|
135
|
+
def test_ensure_column_returns_existing(self):
|
|
136
|
+
handler = YamlHandler()
|
|
137
|
+
model = CommentedMap(
|
|
138
|
+
{
|
|
139
|
+
"name": "test",
|
|
140
|
+
"columns": CommentedSeq(
|
|
141
|
+
[CommentedMap({"name": "existing", "data_type": "int"})]
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
col = handler.ensure_column(model, "existing")
|
|
146
|
+
assert col["data_type"] == "int"
|
|
147
|
+
assert len(model["columns"]) == 1
|
|
148
|
+
|
|
149
|
+
def test_update_column(self):
|
|
150
|
+
handler = YamlHandler()
|
|
151
|
+
col = CommentedMap({"name": "test"})
|
|
152
|
+
handler.update_column(col, data_type="text", description="A test column")
|
|
153
|
+
|
|
154
|
+
assert col["data_type"] == "text"
|
|
155
|
+
assert col["description"] == "A test column"
|
|
156
|
+
|
|
157
|
+
def test_update_column_skips_none(self):
|
|
158
|
+
handler = YamlHandler()
|
|
159
|
+
col = CommentedMap({"name": "test"})
|
|
160
|
+
handler.update_column(col, data_type=None, description=None)
|
|
161
|
+
assert "data_type" not in col
|
|
162
|
+
assert "description" not in col
|
|
163
|
+
|
|
164
|
+
def test_update_columns_batch(self):
|
|
165
|
+
handler = YamlHandler()
|
|
166
|
+
model = CommentedMap({"name": "test"})
|
|
167
|
+
columns_data = [
|
|
168
|
+
{"name": "id", "data_type": "int"},
|
|
169
|
+
{"name": "name", "data_type": "text", "description": "User name"},
|
|
170
|
+
]
|
|
171
|
+
handler.update_columns_batch(model, columns_data)
|
|
172
|
+
|
|
173
|
+
assert len(model["columns"]) == 2
|
|
174
|
+
assert model["columns"][0]["data_type"] == "int"
|
|
175
|
+
assert model["columns"][1]["description"] == "User name"
|
|
176
|
+
|
|
177
|
+
def test_get_columns(self):
|
|
178
|
+
handler = YamlHandler()
|
|
179
|
+
model = CommentedMap(
|
|
180
|
+
{
|
|
181
|
+
"name": "test",
|
|
182
|
+
"columns": [
|
|
183
|
+
{"name": "id", "data_type": "int", "description": "Primary key"},
|
|
184
|
+
{"name": "name", "data_type": "text"},
|
|
185
|
+
],
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
cols = handler.get_columns(model)
|
|
189
|
+
assert len(cols) == 2
|
|
190
|
+
assert cols[0]["name"] == "id"
|
|
191
|
+
assert cols[0]["description"] == "Primary key"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestYamlHandlerRelationshipTests:
|
|
195
|
+
"""Test relationship test operations."""
|
|
196
|
+
|
|
197
|
+
def test_add_relationship_test(self):
|
|
198
|
+
handler = YamlHandler()
|
|
199
|
+
col = CommentedMap({"name": "user_id"})
|
|
200
|
+
handler.add_relationship_test(col, "users", "id")
|
|
201
|
+
|
|
202
|
+
assert "data_tests" in col
|
|
203
|
+
assert len(col["data_tests"]) == 1
|
|
204
|
+
rel = col["data_tests"][0]["relationships"]
|
|
205
|
+
assert rel["arguments"]["to"] == "ref('users')"
|
|
206
|
+
assert rel["arguments"]["field"] == "id"
|
|
207
|
+
|
|
208
|
+
def test_add_relationship_test_replaces_existing(self):
|
|
209
|
+
handler = YamlHandler()
|
|
210
|
+
col = CommentedMap(
|
|
211
|
+
{
|
|
212
|
+
"name": "user_id",
|
|
213
|
+
"data_tests": CommentedSeq(
|
|
214
|
+
[
|
|
215
|
+
CommentedMap(
|
|
216
|
+
{"relationships": {"to": "ref('old')", "field": "old_id"}}
|
|
217
|
+
),
|
|
218
|
+
CommentedMap({"not_null": {}}),
|
|
219
|
+
]
|
|
220
|
+
),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
handler.add_relationship_test(col, "new_model", "new_id")
|
|
224
|
+
|
|
225
|
+
# Should replace relationship but keep other tests
|
|
226
|
+
assert len(col["data_tests"]) == 2
|
|
227
|
+
rel_test = next(t for t in col["data_tests"] if "relationships" in t)
|
|
228
|
+
assert rel_test["relationships"]["arguments"]["to"] == "ref('new_model')"
|