trellis-datamodel 0.3.3__py3-none-any.whl → 0.4.0__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 (31) hide show
  1. trellis_datamodel/adapters/base.py +5 -1
  2. trellis_datamodel/adapters/dbt_core.py +79 -8
  3. trellis_datamodel/config.py +43 -1
  4. trellis_datamodel/routes/__init__.py +2 -0
  5. trellis_datamodel/routes/lineage.py +60 -0
  6. trellis_datamodel/routes/manifest.py +6 -0
  7. trellis_datamodel/server.py +2 -1
  8. trellis_datamodel/services/__init__.py +2 -0
  9. trellis_datamodel/services/lineage.py +427 -0
  10. trellis_datamodel/static/_app/immutable/assets/0.DRr1NRor.css +1 -0
  11. trellis_datamodel/static/_app/immutable/chunks/{CXDUumOQ.js → COKQndWa.js} +1 -1
  12. trellis_datamodel/static/_app/immutable/entry/{app.abCkWeAJ.js → app.BA1wC-Z2.js} +2 -2
  13. trellis_datamodel/static/_app/immutable/entry/start.Cq8bDFFs.js +1 -0
  14. trellis_datamodel/static/_app/immutable/nodes/{1.J_r941Qf.js → 1.CITYWtIe.js} +1 -1
  15. trellis_datamodel/static/_app/immutable/nodes/2.DBgiABuH.js +27 -0
  16. trellis_datamodel/static/_app/version.json +1 -1
  17. trellis_datamodel/static/index.html +6 -6
  18. trellis_datamodel/tests/test_dbt_schema.py +260 -4
  19. trellis_datamodel/tests/test_yaml_handler.py +228 -0
  20. trellis_datamodel/utils/yaml_handler.py +131 -20
  21. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/METADATA +8 -4
  22. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/RECORD +28 -25
  23. trellis_datamodel/static/_app/immutable/assets/0.ByDwyx3a.css +0 -1
  24. trellis_datamodel/static/_app/immutable/entry/start.B7CjH6Z7.js +0 -1
  25. trellis_datamodel/static/_app/immutable/nodes/2.WqbMkq6o.js +0 -27
  26. /trellis_datamodel/static/_app/immutable/nodes/{0.bFI_DI3G.js → 0.CXLfbIn-.js} +0 -0
  27. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/WHEEL +0 -0
  28. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/entry_points.txt +0 -0
  29. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/licenses/LICENSE +0 -0
  30. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/licenses/NOTICE +0 -0
  31. {trellis_datamodel-0.3.3.dist-info → trellis_datamodel-0.4.0.dist-info}/top_level.txt +0 -0
@@ -1 +1 @@
1
- {"version":"1766080526003"}
1
+ {"version":"1767080961734"}
@@ -7,11 +7,11 @@
7
7
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
8
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
9
 
10
- <link rel="modulepreload" href="/_app/immutable/entry/start.B7CjH6Z7.js">
11
- <link rel="modulepreload" href="/_app/immutable/chunks/CXDUumOQ.js">
10
+ <link rel="modulepreload" href="/_app/immutable/entry/start.Cq8bDFFs.js">
11
+ <link rel="modulepreload" href="/_app/immutable/chunks/COKQndWa.js">
12
12
  <link rel="modulepreload" href="/_app/immutable/chunks/zXDdy2c_.js">
13
13
  <link rel="modulepreload" href="/_app/immutable/chunks/8ZaN1sxc.js">
14
- <link rel="modulepreload" href="/_app/immutable/entry/app.abCkWeAJ.js">
14
+ <link rel="modulepreload" href="/_app/immutable/entry/app.BA1wC-Z2.js">
15
15
  <link rel="modulepreload" href="/_app/immutable/chunks/QRltG_J6.js">
16
16
  <link rel="modulepreload" href="/_app/immutable/chunks/DDNfEvut.js">
17
17
  <link rel="modulepreload" href="/_app/immutable/chunks/BfBfOTnK.js">
@@ -21,15 +21,15 @@
21
21
  <div style="display: contents">
22
22
  <script>
23
23
  {
24
- __sveltekit_nmm995 = {
24
+ __sveltekit_vj8yr8 = {
25
25
  base: ""
26
26
  };
27
27
 
28
28
  const element = document.currentScript.parentElement;
29
29
 
30
30
  Promise.all([
31
- import("/_app/immutable/entry/start.B7CjH6Z7.js"),
32
- import("/_app/immutable/entry/app.abCkWeAJ.js")
31
+ import("/_app/immutable/entry/start.Cq8bDFFs.js"),
32
+ import("/_app/immutable/entry/app.BA1wC-Z2.js")
33
33
  ]).then(([kit, app]) => {
34
34
  kit.start(app, element);
35
35
  });
@@ -244,6 +244,260 @@ class TestSyncDbtTests:
244
244
  }
245
245
  ]
246
246
 
247
+ def test_syncs_many_to_one_relationship(self, test_client, temp_dir, temp_data_model_path):
248
+ """
249
+ Ensure many_to_one relationships write FK test to source entity (the "many" side).
250
+ """
251
+ data_model = {
252
+ "version": 0.1,
253
+ "entities": [
254
+ {
255
+ "id": "customer_entity",
256
+ "label": "Customers",
257
+ "dbt_model": "model.project.customers",
258
+ "position": {"x": 0, "y": 0},
259
+ },
260
+ {
261
+ "id": "order_entity",
262
+ "label": "Orders",
263
+ "dbt_model": "model.project.orders",
264
+ "drafted_fields": [{"name": "customer_id", "datatype": "int"}],
265
+ "position": {"x": 100, "y": 0},
266
+ },
267
+ ],
268
+ "relationships": [
269
+ {
270
+ "source": "order_entity", # Source is "many" side, so FK should be here
271
+ "target": "customer_entity", # Target is "one" side (PK)
272
+ "type": "many_to_one",
273
+ "source_field": "customer_id", # FK field
274
+ "target_field": "id", # PK field
275
+ }
276
+ ],
277
+ }
278
+
279
+ # Persist data model
280
+ with open(temp_data_model_path, "w") as f:
281
+ yaml.dump(data_model, f)
282
+
283
+ response = test_client.post("/api/sync-dbt-tests")
284
+ assert response.status_code == 200
285
+
286
+ # orders.yml should contain a relationship test (FK is on source/orders)
287
+ orders_yml = os.path.join(temp_dir, "models", "3_core", "orders.yml")
288
+ assert os.path.exists(orders_yml)
289
+ with open(orders_yml, "r") as f:
290
+ schema = yaml.safe_load(f)
291
+
292
+ rel_tests = schema["models"][0]["columns"][0]["data_tests"]
293
+ assert rel_tests == [
294
+ {
295
+ "relationships": {
296
+ "arguments": {"to": "ref('customers')", "field": "id"},
297
+ }
298
+ }
299
+ ]
300
+
301
+ # customers.yml should NOT have a relationship test (PK is on target/customers)
302
+ customers_yml = os.path.join(temp_dir, "models", "3_core", "customers.yml")
303
+ if os.path.exists(customers_yml):
304
+ with open(customers_yml, "r") as f:
305
+ customer_schema = yaml.safe_load(f)
306
+ # If customers.yml exists, it shouldn't have relationship tests for this relationship
307
+ if "models" in customer_schema and len(customer_schema["models"]) > 0:
308
+ if "columns" in customer_schema["models"][0]:
309
+ for col in customer_schema["models"][0]["columns"]:
310
+ if col.get("name") == "id":
311
+ # PK field shouldn't have relationship test pointing back
312
+ tests = col.get("data_tests", [])
313
+ for test in tests:
314
+ if "relationships" in test:
315
+ rel = test["relationships"]
316
+ ref = rel.get("arguments", {}).get("to", "") or rel.get("to", "")
317
+ assert "ref('orders')" not in ref
318
+
319
+ def test_removes_stale_relationship_tests_when_type_changes(
320
+ self, test_client, temp_dir, temp_data_model_path
321
+ ):
322
+ """
323
+ When relationship type changes (e.g., one_to_many -> many_to_one),
324
+ stale relationship tests should be removed from old FK location and
325
+ added to new FK location.
326
+ """
327
+ # Start with one_to_many relationship (FK on target/orders)
328
+ data_model = {
329
+ "version": 0.1,
330
+ "entities": [
331
+ {
332
+ "id": "customers",
333
+ "label": "Customers",
334
+ "dbt_model": "model.project.customers",
335
+ "position": {"x": 0, "y": 0},
336
+ },
337
+ {
338
+ "id": "orders",
339
+ "label": "Orders",
340
+ "dbt_model": "model.project.orders",
341
+ "drafted_fields": [{"name": "customer_id", "datatype": "int"}],
342
+ "position": {"x": 100, "y": 0},
343
+ },
344
+ ],
345
+ "relationships": [
346
+ {
347
+ "source": "customers",
348
+ "target": "orders",
349
+ "type": "one_to_many", # FK on target (orders)
350
+ "source_field": "id",
351
+ "target_field": "customer_id",
352
+ }
353
+ ],
354
+ }
355
+ with open(temp_data_model_path, "w") as f:
356
+ yaml.dump(data_model, f)
357
+
358
+ # First sync: FK should be on orders
359
+ response = test_client.post("/api/sync-dbt-tests")
360
+ assert response.status_code == 200
361
+
362
+ orders_yml = os.path.join(temp_dir, "models", "3_core", "orders.yml")
363
+ assert os.path.exists(orders_yml)
364
+ with open(orders_yml, "r") as f:
365
+ schema = yaml.safe_load(f)
366
+
367
+ # Verify FK test is on orders.customer_id
368
+ rel_tests = schema["models"][0]["columns"][0]["data_tests"]
369
+ assert len(rel_tests) == 1
370
+ assert "relationships" in rel_tests[0]
371
+ assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('customers')"
372
+
373
+ # Now change relationship type to many_to_one (FK should move to source/customers)
374
+ data_model["relationships"][0]["type"] = "many_to_one"
375
+ # Swap fields: FK now on source (customers), PK on target (orders)
376
+ data_model["relationships"][0]["source_field"] = "customer_id"
377
+ data_model["relationships"][0]["target_field"] = "id"
378
+ # Swap source/target to reflect new direction
379
+ data_model["relationships"][0]["source"] = "orders"
380
+ data_model["relationships"][0]["target"] = "customers"
381
+
382
+ with open(temp_data_model_path, "w") as f:
383
+ yaml.dump(data_model, f)
384
+
385
+ # Second sync: FK should move to orders, old FK on customers should be removed
386
+ response = test_client.post("/api/sync-dbt-tests")
387
+ assert response.status_code == 200
388
+
389
+ # Verify orders.yml still has the test (FK is still on orders, just different semantics)
390
+ with open(orders_yml, "r") as f:
391
+ schema = yaml.safe_load(f)
392
+
393
+ rel_tests = schema["models"][0]["columns"][0]["data_tests"]
394
+ assert len(rel_tests) == 1
395
+ assert "relationships" in rel_tests[0]
396
+ assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('customers')"
397
+
398
+ # Verify customers.yml does NOT have a relationship test
399
+ # (even if it exists, it shouldn't have a test pointing back to orders)
400
+ customers_yml = os.path.join(temp_dir, "models", "3_core", "customers.yml")
401
+ if os.path.exists(customers_yml):
402
+ with open(customers_yml, "r") as f:
403
+ customer_schema = yaml.safe_load(f)
404
+ if "models" in customer_schema and len(customer_schema["models"]) > 0:
405
+ if "columns" in customer_schema["models"][0]:
406
+ for col in customer_schema["models"][0]["columns"]:
407
+ if col.get("name") == "id":
408
+ tests = col.get("data_tests", [])
409
+ for test in tests:
410
+ if "relationships" in test:
411
+ rel = test["relationships"]
412
+ ref = rel.get("arguments", {}).get("to", "") or rel.get("to", "")
413
+ assert "ref('orders')" not in ref
414
+
415
+ def test_removes_stale_tests_when_swapping_one_to_many_to_many_to_one(
416
+ self, test_client, temp_dir, temp_data_model_path
417
+ ):
418
+ """
419
+ Test the specific bug scenario: swapping one_to_many to many_to_one
420
+ should move FK from target to source and remove stale test from target.
421
+ """
422
+ # Create initial state: one_to_many with FK on target
423
+ data_model = {
424
+ "version": 0.1,
425
+ "entities": [
426
+ {
427
+ "id": "department",
428
+ "label": "Department",
429
+ "dbt_model": "model.project.department",
430
+ "drafted_fields": [{"name": "department_id", "datatype": "text"}],
431
+ },
432
+ {
433
+ "id": "cool_stuff",
434
+ "label": "Cool Stuff",
435
+ "dbt_model": "model.project.cool_stuff",
436
+ "drafted_fields": [{"name": "department_id", "datatype": "text"}],
437
+ },
438
+ ],
439
+ "relationships": [
440
+ {
441
+ "source": "department",
442
+ "target": "cool_stuff",
443
+ "type": "one_to_many", # FK on target (cool_stuff)
444
+ "source_field": "department_id",
445
+ "target_field": "department_id",
446
+ }
447
+ ],
448
+ }
449
+ with open(temp_data_model_path, "w") as f:
450
+ yaml.dump(data_model, f)
451
+
452
+ # Initial sync: FK should be on cool_stuff
453
+ response = test_client.post("/api/sync-dbt-tests")
454
+ assert response.status_code == 200
455
+
456
+ cool_stuff_yml = os.path.join(temp_dir, "models", "3_core", "cool_stuff.yml")
457
+ assert os.path.exists(cool_stuff_yml)
458
+ with open(cool_stuff_yml, "r") as f:
459
+ schema = yaml.safe_load(f)
460
+
461
+ # Verify FK test exists on cool_stuff.department_id
462
+ rel_tests = schema["models"][0]["columns"][0]["data_tests"]
463
+ assert len(rel_tests) == 1
464
+ assert "relationships" in rel_tests[0]
465
+
466
+ # Now swap to many_to_one (FK should move to department)
467
+ data_model["relationships"][0]["type"] = "many_to_one"
468
+ # Note: source/target stay the same, only type changes (as per fixed swap logic)
469
+ with open(temp_data_model_path, "w") as f:
470
+ yaml.dump(data_model, f)
471
+
472
+ # Second sync: FK should move to department, stale test removed from cool_stuff
473
+ response = test_client.post("/api/sync-dbt-tests")
474
+ assert response.status_code == 200
475
+
476
+ # Verify cool_stuff.yml no longer has the relationship test
477
+ with open(cool_stuff_yml, "r") as f:
478
+ schema = yaml.safe_load(f)
479
+
480
+ # cool_stuff.department_id should NOT have relationship test anymore
481
+ if "columns" in schema["models"][0]:
482
+ for col in schema["models"][0]["columns"]:
483
+ if col.get("name") == "department_id":
484
+ tests = col.get("data_tests", [])
485
+ # Should have no relationship tests (or no tests at all)
486
+ for test in tests:
487
+ assert "relationships" not in test
488
+
489
+ # Verify department.yml now has the relationship test
490
+ department_yml = os.path.join(temp_dir, "models", "3_core", "department.yml")
491
+ assert os.path.exists(department_yml)
492
+ with open(department_yml, "r") as f:
493
+ dept_schema = yaml.safe_load(f)
494
+
495
+ # department.department_id should have relationship test pointing to cool_stuff
496
+ rel_tests = dept_schema["models"][0]["columns"][0]["data_tests"]
497
+ assert len(rel_tests) == 1
498
+ assert "relationships" in rel_tests[0]
499
+ assert rel_tests[0]["relationships"]["arguments"]["to"] == "ref('cool_stuff')"
500
+
247
501
 
248
502
  class TestGetModelSchema:
249
503
  """Tests for GET /api/models/{model_name}/schema endpoint."""
@@ -653,7 +907,11 @@ class TestInferRelationships:
653
907
  "label": "Customers",
654
908
  "additional_models": ["model.project.customers_alt"],
655
909
  },
656
- {"id": "orders", "label": "Orders", "dbt_model": "model.project.orders"},
910
+ {
911
+ "id": "orders",
912
+ "label": "Orders",
913
+ "dbt_model": "model.project.orders",
914
+ },
657
915
  ],
658
916
  }
659
917
  with open(temp_data_model_path, "w") as f:
@@ -847,9 +1105,7 @@ class TestModelSchemaVersionHandling:
847
1105
  self._write_versioned_manifest(temp_dir)
848
1106
  self._write_versioned_schema(temp_dir)
849
1107
 
850
- response = test_client.get(
851
- "/api/models/player/schema", params={"version": 2}
852
- )
1108
+ response = test_client.get("/api/models/player/schema", params={"version": 2})
853
1109
  assert response.status_code == 200
854
1110
 
855
1111
  schema = response.json()
@@ -226,3 +226,231 @@ class TestYamlHandlerRelationshipTests:
226
226
  assert len(col["data_tests"]) == 2
227
227
  rel_test = next(t for t in col["data_tests"] if "relationships" in t)
228
228
  assert rel_test["relationships"]["arguments"]["to"] == "ref('new_model')"
229
+
230
+
231
+ class TestYamlIndentation:
232
+ """Test YAML indentation formatting (regression test for issue #19)."""
233
+
234
+ def test_models_list_indentation(self, temp_dir):
235
+ """Test that models list items are properly indented under the models key."""
236
+ handler = YamlHandler()
237
+ file_path = os.path.join(temp_dir, "test_indentation.yml")
238
+
239
+ # Create data with a plain Python list (simulates real usage in dbt_core.py)
240
+ data = {"version": 2, "models": [{"name": "test_model", "description": "Test"}]}
241
+ handler.save_file(file_path, data)
242
+
243
+ # Read the raw file text
244
+ with open(file_path, "r") as f:
245
+ content = f.read()
246
+
247
+ # Assert proper indentation: models list items should be indented 2 spaces
248
+ # Expected format:
249
+ # models:
250
+ # - name: test_model
251
+ assert (
252
+ "models:\n - name:" in content
253
+ ), f"Expected proper indentation, got:\n{content}"
254
+
255
+ def test_ensure_model_normalizes_plain_list(self, temp_dir):
256
+ """Test that ensure_model normalizes plain Python lists to CommentedSeq."""
257
+ handler = YamlHandler()
258
+ file_path = os.path.join(temp_dir, "test_normalize.yml")
259
+
260
+ # Start with a plain Python list
261
+ data = {"version": 2, "models": []}
262
+
263
+ # Call ensure_model which should normalize the list
264
+ handler.ensure_model(data, "new_model")
265
+
266
+ # Verify it's now a CommentedSeq
267
+ assert isinstance(data["models"], CommentedSeq)
268
+
269
+ # Save and verify indentation
270
+ handler.save_file(file_path, data)
271
+
272
+ with open(file_path, "r") as f:
273
+ content = f.read()
274
+
275
+ assert (
276
+ "models:\n - name:" in content
277
+ ), f"Expected proper indentation after normalization, got:\n{content}"
278
+
279
+
280
+ class TestTagHandling:
281
+ """Test tag handling improvements (issue #20)."""
282
+
283
+ def test_update_model_tags_skips_empty_tags(self):
284
+ """Test that empty tags arrays are not written to YAML (Point 1)."""
285
+ handler = YamlHandler()
286
+ model = CommentedMap({"name": "test"})
287
+
288
+ # Update with empty tags - should not create tags key
289
+ handler.update_model_tags(model, [])
290
+
291
+ assert "tags" not in model
292
+ assert "config" not in model
293
+
294
+ def test_update_model_tags_removes_existing_empty_tags(self):
295
+ """Test that existing tags are removed when updated to empty (Point 1)."""
296
+ handler = YamlHandler()
297
+ model = CommentedMap({"name": "test", "tags": ["old_tag"]})
298
+
299
+ # Update with empty tags - should remove existing tags
300
+ handler.update_model_tags(model, [])
301
+
302
+ assert "tags" not in model
303
+
304
+ def test_update_model_tags_removes_config_tags_when_empty(self):
305
+ """Test that config.tags are removed when updated to empty (Point 1)."""
306
+ handler = YamlHandler()
307
+ model = CommentedMap(
308
+ {
309
+ "name": "test",
310
+ "config": CommentedMap({"tags": ["old_tag"], "materialized": "table"}),
311
+ }
312
+ )
313
+
314
+ # Update with empty tags - should remove config.tags but keep config
315
+ handler.update_model_tags(model, [])
316
+
317
+ assert "tags" not in model["config"]
318
+ assert model["config"]["materialized"] == "table"
319
+
320
+ def test_update_version_tags_skips_empty_tags(self):
321
+ """Test that empty tags arrays are not written for versions (Point 1)."""
322
+ handler = YamlHandler()
323
+ version = CommentedMap({"v": 2})
324
+
325
+ # Update with empty tags - should not create config
326
+ handler.update_version_tags(version, [])
327
+
328
+ assert "config" not in version
329
+
330
+ def test_update_version_tags_removes_existing_empty_tags(self):
331
+ """Test that existing version tags are removed when updated to empty (Point 1)."""
332
+ handler = YamlHandler()
333
+ version = CommentedMap({"v": 2, "config": CommentedMap({"tags": ["old_tag"]})})
334
+
335
+ # Update with empty tags - should remove config.tags
336
+ handler.update_version_tags(version, [])
337
+
338
+ assert "tags" not in version["config"]
339
+
340
+ def test_config_placement_after_description(self, temp_dir):
341
+ """Test that config block is placed after description (Point 2)."""
342
+ handler = YamlHandler()
343
+ file_path = os.path.join(temp_dir, "test_config_placement.yml")
344
+
345
+ # Create model with name and description, then add tags
346
+ model = CommentedMap()
347
+ model["name"] = "test_model"
348
+ model["description"] = "A test model"
349
+
350
+ handler.update_model_tags(model, ["core"])
351
+
352
+ # Save and check order
353
+ data = {"version": 2, "models": [model]}
354
+ handler.save_file(file_path, data)
355
+
356
+ with open(file_path, "r") as f:
357
+ content = f.read()
358
+
359
+ # Config should appear after description
360
+ desc_pos = content.find("description:")
361
+ config_pos = content.find("config:")
362
+
363
+ assert desc_pos > 0, "Description should be present"
364
+ assert config_pos > 0, "Config should be present"
365
+ assert config_pos > desc_pos, "Config should appear after description"
366
+
367
+ def test_config_placement_before_columns(self, temp_dir):
368
+ """Test that config block is placed before columns (Point 2)."""
369
+ handler = YamlHandler()
370
+ file_path = os.path.join(temp_dir, "test_config_before_columns.yml")
371
+
372
+ # Create model with name, description, and columns
373
+ model = CommentedMap()
374
+ model["name"] = "test_model"
375
+ model["description"] = "A test model"
376
+ model["columns"] = CommentedSeq([CommentedMap({"name": "id"})])
377
+
378
+ # Add tags - should insert config before columns
379
+ handler.update_model_tags(model, ["core"])
380
+
381
+ # Save and check order
382
+ data = {"version": 2, "models": [model]}
383
+ handler.save_file(file_path, data)
384
+
385
+ with open(file_path, "r") as f:
386
+ content = f.read()
387
+
388
+ # Config should appear before columns
389
+ config_pos = content.find("config:")
390
+ columns_pos = content.find("columns:")
391
+
392
+ assert config_pos > 0, "Config should be present"
393
+ assert columns_pos > 0, "Columns should be present"
394
+ assert config_pos < columns_pos, "Config should appear before columns"
395
+
396
+ def test_config_placement_after_name_when_no_description(self, temp_dir):
397
+ """Test that config is placed after name when description is missing (Point 2)."""
398
+ handler = YamlHandler()
399
+ file_path = os.path.join(temp_dir, "test_config_after_name.yml")
400
+
401
+ # Create model with only name
402
+ model = CommentedMap()
403
+ model["name"] = "test_model"
404
+
405
+ handler.update_model_tags(model, ["core"])
406
+
407
+ # Save and check order
408
+ data = {"version": 2, "models": [model]}
409
+ handler.save_file(file_path, data)
410
+
411
+ with open(file_path, "r") as f:
412
+ content = f.read()
413
+
414
+ # Config should appear after name
415
+ name_pos = content.find("name:")
416
+ config_pos = content.find("config:")
417
+
418
+ assert name_pos > 0, "Name should be present"
419
+ assert config_pos > 0, "Config should be present"
420
+ assert config_pos > name_pos, "Config should appear after name"
421
+
422
+ def test_version_config_placement(self, temp_dir):
423
+ """Test that config block in versions is placed correctly (Point 2)."""
424
+ handler = YamlHandler()
425
+ file_path = os.path.join(temp_dir, "test_version_config.yml")
426
+
427
+ # Create version with description
428
+ version = CommentedMap()
429
+ version["v"] = 2
430
+ version["description"] = "Version 2"
431
+
432
+ handler.update_version_tags(version, ["core"])
433
+
434
+ # Save and check order
435
+ model = CommentedMap()
436
+ model["name"] = "test_model"
437
+ model["versions"] = CommentedSeq([version])
438
+ data = {"version": 2, "models": [model]}
439
+ handler.save_file(file_path, data)
440
+
441
+ with open(file_path, "r") as f:
442
+ content = f.read()
443
+
444
+ # In the version block, config should appear after description
445
+ lines = content.split("\n")
446
+ v_line = next(i for i, line in enumerate(lines) if "v:" in line)
447
+ desc_line = next(
448
+ i for i, line in enumerate(lines) if i > v_line and "description:" in line
449
+ )
450
+ config_line = next(
451
+ i for i, line in enumerate(lines) if i > v_line and "config:" in line
452
+ )
453
+
454
+ assert (
455
+ config_line > desc_line
456
+ ), "Config should appear after description in version block"