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,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')"
@@ -0,0 +1,2 @@
1
+ """Utility modules."""
2
+