datajunction 0.0.149__tar.gz → 0.0.151__tar.gz

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 (162) hide show
  1. {datajunction-0.0.149 → datajunction-0.0.151}/PKG-INFO +1 -1
  2. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/__about__.py +1 -1
  3. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/_internal.py +20 -4
  4. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/deployment.py +30 -104
  5. {datajunction-0.0.149 → datajunction-0.0.151}/tests/conftest.py +18 -14
  6. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_deploy.py +30 -109
  7. {datajunction-0.0.149 → datajunction-0.0.151}/.coveragerc +0 -0
  8. {datajunction-0.0.149 → datajunction-0.0.151}/.gitignore +0 -0
  9. {datajunction-0.0.149 → datajunction-0.0.151}/.pre-commit-config.yaml +0 -0
  10. {datajunction-0.0.149 → datajunction-0.0.151}/LICENSE.txt +0 -0
  11. {datajunction-0.0.149 → datajunction-0.0.151}/Makefile +0 -0
  12. {datajunction-0.0.149 → datajunction-0.0.151}/README.md +0 -0
  13. {datajunction-0.0.149 → datajunction-0.0.151}/claude_desktop_config.example.json +0 -0
  14. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/__init__.py +0 -0
  15. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/_base.py +0 -0
  16. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/admin.py +0 -0
  17. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/builder.py +0 -0
  18. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/cli.py +0 -0
  19. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/client.py +0 -0
  20. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/compile.py +0 -0
  21. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/exceptions.py +0 -0
  22. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/mcp/__init__.py +0 -0
  23. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/mcp/cli.py +0 -0
  24. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/mcp/config.py +0 -0
  25. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/models.py +0 -0
  26. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/nodes.py +0 -0
  27. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/rendering.py +0 -0
  28. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/init_system_nodes.py +0 -0
  29. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/date.dimension.yaml +0 -0
  30. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/dimension_link.dimension.yaml +0 -0
  31. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/dj.yaml +0 -0
  32. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/is_active.dimension.yaml +0 -0
  33. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/materialization.dimension.yaml +0 -0
  34. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/node_type.dimension.yaml +0 -0
  35. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/node_without_description.metric.yaml +0 -0
  36. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/nodes.dimension.yaml +0 -0
  37. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/number_of_materializations.metric.yaml +0 -0
  38. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/number_of_nodes.metric.yaml +0 -0
  39. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/seed/nodes/user.dimension.yaml +0 -0
  40. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/skills/datajunction.md +0 -0
  41. {datajunction-0.0.149 → datajunction-0.0.151}/datajunction/tags.py +0 -0
  42. {datajunction-0.0.149 → datajunction-0.0.151}/pyproject.toml +0 -0
  43. {datajunction-0.0.149 → datajunction-0.0.151}/setup.cfg +0 -0
  44. {datajunction-0.0.149 → datajunction-0.0.151}/tests/__init__.py +0 -0
  45. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/dj.yaml +0 -0
  46. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/companies.yaml +0 -0
  47. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/companies_dim.yaml +0 -0
  48. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/contractor.yaml +0 -0
  49. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/contractors.yaml +0 -0
  50. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/us_state.yaml +0 -0
  51. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/deploy0/roads/us_states.yaml +0 -0
  52. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/dj.yaml +0 -0
  53. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/avg_length_of_employment.metric.yaml +0 -0
  54. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/avg_repair_price.metric.yaml +0 -0
  55. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/avg_time_to_dispatch.metric.yaml +0 -0
  56. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/contractor.dimension.yaml +0 -0
  57. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/contractors.source.yaml +0 -0
  58. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/date.source.yaml +0 -0
  59. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/date_dim.dimension.yaml +0 -0
  60. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/dispatcher.dimension.yaml +0 -0
  61. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/dispatchers.source.yaml +0 -0
  62. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/hard_hat.dimension.yaml +0 -0
  63. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/hard_hat_state.source.yaml +0 -0
  64. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/hard_hats.source.yaml +0 -0
  65. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/local_hard_hats.dimension.yaml +0 -0
  66. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/municipality.source.yaml +0 -0
  67. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/municipality_dim.dimension.yaml +0 -0
  68. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/municipality_municipality_type.source.yaml +0 -0
  69. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/municipality_type.source.yaml +0 -0
  70. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/national_level_agg.transform.yaml +0 -0
  71. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/num_repair_orders.metric.yaml +0 -0
  72. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/regional_level_agg.transform.yaml +0 -0
  73. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/regional_repair_efficiency.metric.yaml +0 -0
  74. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_order.dimension.yaml +0 -0
  75. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_order_details.source.yaml +0 -0
  76. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_order_transform.transform.yaml +0 -0
  77. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_orders.source.yaml +0 -0
  78. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_orders_cube.cube.yaml +0 -0
  79. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/repair_type.source.yaml +0 -0
  80. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/total_repair_cost.metric.yaml +0 -0
  81. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/total_repair_order_discounts.metric.yaml +0 -0
  82. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/us_region.source.yaml +0 -0
  83. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/us_state.dimension.yaml +0 -0
  84. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project1/roads/us_states.source.yaml +0 -0
  85. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project10/dj.yaml +0 -0
  86. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/avg_length_of_employment.metric.yaml +0 -0
  87. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/avg_repair_price.metric.yaml +0 -0
  88. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/avg_time_to_dispatch.metric.yaml +0 -0
  89. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/contractor.dimension.yaml +0 -0
  90. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/contractors.source.yaml +0 -0
  91. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/date.source.yaml +0 -0
  92. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/date_dim.dimension.yaml +0 -0
  93. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/dispatcher.dimension.yaml +0 -0
  94. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/dispatchers.source.yaml +0 -0
  95. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/dj.yaml +0 -0
  96. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/hard_hat.dimension.yaml +0 -0
  97. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/hard_hat_state.source.yaml +0 -0
  98. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/hard_hats.source.yaml +0 -0
  99. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/local_hard_hats.dimension.yaml +0 -0
  100. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/municipality.source.yaml +0 -0
  101. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/municipality_dim.dimension.yaml +0 -0
  102. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/municipality_municipality_type.source.yaml +0 -0
  103. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/municipality_type.source.yaml +0 -0
  104. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/national_level_agg.transform.yaml +0 -0
  105. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/num_repair_orders.metric.yaml +0 -0
  106. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/regional_level_agg.transform.yaml +0 -0
  107. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/regional_repair_efficiency.metric.yaml +0 -0
  108. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_order.dimension.yaml +0 -0
  109. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_order_details.source.yaml +0 -0
  110. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_order_transform.transform.yaml +0 -0
  111. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_orders.source.yaml +0 -0
  112. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_orders_cube.cube.yaml +0 -0
  113. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/repair_type.source.yaml +0 -0
  114. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/total_repair_cost.metric.yaml +0 -0
  115. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/total_repair_order_discounts.metric.yaml +0 -0
  116. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/us_region.source.yaml +0 -0
  117. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/us_state.dimension.yaml +0 -0
  118. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project11/us_states.source.yaml +0 -0
  119. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/dj.yaml +0 -0
  120. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/companies.source.yaml +0 -0
  121. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/companies_dim.dimension.yaml +0 -0
  122. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/contractor.dimension.yaml +0 -0
  123. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/contractors.source.yaml +0 -0
  124. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/us_state.dimension.yaml +0 -0
  125. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project12/roads/us_states.source.yaml +0 -0
  126. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project2/dj.yaml +0 -0
  127. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project2/some_node.source.yaml +0 -0
  128. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project3/dj.yaml +0 -0
  129. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project3/some_node.yaml +0 -0
  130. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project4/dj.yaml +0 -0
  131. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project4/very/very/deeply/nested/namespace/some_node.source.yaml +0 -0
  132. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project5/dj.yaml +0 -0
  133. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project5/some_node.a.b.c.source.yaml +0 -0
  134. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project6/dj.yaml +0 -0
  135. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project6/roads/contractor.dimension.yaml +0 -0
  136. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project6/roads/contractors.source.yaml +0 -0
  137. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project7/dj.yaml +0 -0
  138. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project7/roads/contractor.dimension.yaml +0 -0
  139. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project7/roads/contractors.source.yaml +0 -0
  140. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project8/dj.yaml +0 -0
  141. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/dj.yaml +0 -0
  142. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/companies.source.yaml +0 -0
  143. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/companies_dim.dimension.yaml +0 -0
  144. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/contractor.dimension.yaml +0 -0
  145. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/contractors.source.yaml +0 -0
  146. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/us_state.dimension.yaml +0 -0
  147. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples/project9/roads/us_states.source.yaml +0 -0
  148. {datajunction-0.0.149 → datajunction-0.0.151}/tests/examples.py +0 -0
  149. {datajunction-0.0.149 → datajunction-0.0.151}/tests/mcp/README.md +0 -0
  150. {datajunction-0.0.149 → datajunction-0.0.151}/tests/mcp/__init__.py +0 -0
  151. {datajunction-0.0.149 → datajunction-0.0.151}/tests/mcp/test_cli.py +0 -0
  152. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test__internal.py +0 -0
  153. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_admin.py +0 -0
  154. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_base.py +0 -0
  155. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_builder.py +0 -0
  156. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_cli.py +0 -0
  157. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_client.py +0 -0
  158. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_compile.py +0 -0
  159. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_generated_client.py +0 -0
  160. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_integration.py +0 -0
  161. {datajunction-0.0.149 → datajunction-0.0.151}/tests/test_models.py +0 -0
  162. {datajunction-0.0.149 → datajunction-0.0.151}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datajunction
3
- Version: 0.0.149
3
+ Version: 0.0.151
4
4
  Summary: DataJunction client library for connecting to a DataJunction server
5
5
  Project-URL: repository, https://github.com/DataJunction/dj
6
6
  Author-email: DataJunction Authors <yian.shang@gmail.com>
@@ -2,4 +2,4 @@
2
2
  Version for Hatch
3
3
  """
4
4
 
5
- __version__ = "0.0.149"
5
+ __version__ = "0.0.151"
@@ -683,12 +683,28 @@ class DJClient:
683
683
  response = self._session.get(f"/namespaces/{namespace}/export/")
684
684
  return response.json()
685
685
 
686
- def _export_namespace_spec(self, namespace):
686
+ def _export_namespace_yaml_zip(
687
+ self,
688
+ namespace: str,
689
+ existing_zip_bytes: Optional[bytes] = None,
690
+ ) -> bytes:
687
691
  """
688
- Export a deployment spec for a namespace
692
+ Export a namespace as a ZIP of YAML files.
693
+
694
+ If `existing_zip_bytes` is provided, the server merges new content into
695
+ those files, preserving key ordering and comments. Otherwise it falls back
696
+ to the configured git branch or produces a fresh export.
689
697
  """
690
- response = self._session.get(f"/namespaces/{namespace}/export/spec/")
691
- return response.json()
698
+ files = (
699
+ {"existing_zip": ("existing.zip", existing_zip_bytes, "application/zip")}
700
+ if existing_zip_bytes is not None
701
+ else None
702
+ )
703
+ response = self._session.post(
704
+ f"/namespaces/{namespace}/export/yaml",
705
+ files=files,
706
+ )
707
+ return response.content
692
708
 
693
709
  #
694
710
  # Methods for Tags
@@ -56,123 +56,49 @@ class DeploymentService:
56
56
  self.client = client
57
57
  self.console = console or Console()
58
58
 
59
- @staticmethod
60
- def clean_dict(d: dict) -> dict:
61
- """
62
- Recursively remove None, empty list, and empty dict values.
63
- """
64
- result = {}
65
- for k, v in d.items():
66
- if v is None:
67
- continue
68
- if isinstance(v, (list, dict)) and not v:
69
- continue
70
- if isinstance(v, dict):
71
- nested = DeploymentService.clean_dict(v)
72
- if nested: # only include if not empty after cleaning
73
- result[k] = nested
74
- else:
75
- result[k] = v # type: ignore
76
- return result
77
-
78
- @staticmethod
79
- def filter_node_for_export(node: dict) -> dict:
80
- """
81
- Filter a node dict for export to YAML.
82
-
83
- For columns:
84
- - Cubes: columns are always excluded (they're inferred from metrics/dimensions)
85
- - Other nodes: only includes columns with meaningful customizations
86
- (display_name different from name, attributes, description, or partition).
87
- Column types are excluded - let DJ infer them from the query/source.
88
- """
89
- result = DeploymentService.clean_dict(node)
90
-
91
- # Cubes should never have columns in export - they're inferred from metrics/dimensions
92
- if result.get("node_type") == "cube":
93
- result.pop("columns", None)
94
- # For other nodes, filter columns to only include meaningful customizations
95
- elif "columns" in result and result["columns"]:
96
- filtered_columns = []
97
- for col in result["columns"]:
98
- # Check for meaningful customizations
99
- has_custom_display = col.get("display_name") and col.get(
100
- "display_name",
101
- ) != col.get("name")
102
- has_attributes = bool(col.get("attributes"))
103
- has_description = bool(col.get("description"))
104
- has_partition = bool(col.get("partition"))
105
-
106
- if (
107
- has_custom_display
108
- or has_attributes
109
- or has_description
110
- or has_partition
111
- ):
112
- # Include column but exclude type (let DJ infer)
113
- filtered_col = {
114
- k: v
115
- for k, v in col.items()
116
- if k != "type" and v # Exclude type and empty values
117
- }
118
- filtered_columns.append(filtered_col)
119
-
120
- if filtered_columns:
121
- result["columns"] = filtered_columns
122
- else:
123
- # Remove columns entirely if none have customizations
124
- del result["columns"]
125
-
126
- return result
127
-
128
59
  def pull(
129
60
  self,
130
61
  namespace: str,
131
62
  target_path: Union[str, Path],
132
- ignore_existing_files: bool = False,
133
63
  ):
134
64
  """
135
65
  Export a namespace to a local project.
66
+
67
+ Pulls the YAML files from the server's `/export/yaml` endpoint, which
68
+ runs every node through the same serializer (`node_spec_to_yaml`) used
69
+ by the UI sync-to-git flow. This way `dj pull` and a UI export produce
70
+ identical YAML for the same node state.
71
+
72
+ When the target directory already contains YAML files, they are uploaded
73
+ to the server so it can merge new content into them — preserving key
74
+ ordering, inline comments, and scalar styles. This means a `dj pull`
75
+ against an already-populated directory produces minimal diffs.
136
76
  """
137
- path = Path(target_path)
138
- if any(path.iterdir()) and not ignore_existing_files:
139
- raise DJClientException("The target path must be empty")
140
- deployment_spec = self.client._export_namespace_spec(namespace)
77
+ import io
78
+ import zipfile
141
79
 
142
- namespace = deployment_spec["namespace"]
143
- nodes: list[dict[str, Any]] = deployment_spec.get("nodes", [])
144
80
  base_path = Path(target_path)
145
81
  base_path.mkdir(parents=True, exist_ok=True)
146
82
 
147
- # Create a YAML for each node in the appropriate namespace folder
148
- for node in nodes:
149
- node_name = node["name"]
150
- # Namespace folder is everything except the last part of the node
151
- node_parts = node_name.replace("${prefix}", "").split(".")
152
- node_namespace_path = base_path.joinpath(*node_parts[:-1])
153
- node_namespace_path.mkdir(parents=True, exist_ok=True)
154
-
155
- # File name is the last part of the node
156
- file_name = node_parts[-1] + ".yaml"
157
- file_path = node_namespace_path / file_name
158
-
159
- # Write YAML for this node (filter columns for cleaner output)
160
- with open(file_path, "w") as yaml_file:
161
- yaml.dump(
162
- DeploymentService.filter_node_for_export(node),
163
- yaml_file,
164
- sort_keys=False,
165
- )
83
+ existing_zip_bytes = None
84
+ existing_yaml_files = list(base_path.rglob("*.yaml"))
85
+ if existing_yaml_files:
86
+ buf = io.BytesIO()
87
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
88
+ for yaml_file in existing_yaml_files:
89
+ zf.writestr(
90
+ str(yaml_file.relative_to(base_path)),
91
+ yaml_file.read_bytes(),
92
+ )
93
+ existing_zip_bytes = buf.getvalue()
166
94
 
167
- # Write top-level dj.yaml with full deployment info
168
- dj_yaml_path = base_path / "dj.yaml"
169
- with open(dj_yaml_path, "w") as yaml_file:
170
- project_spec = {
171
- "name": f"Project {namespace} (Autogenerated)",
172
- "description": f"This is an autogenerated project for namespace {namespace}",
173
- "namespace": namespace,
174
- }
175
- yaml.safe_dump(project_spec, yaml_file, sort_keys=False)
95
+ zip_bytes = self.client._export_namespace_yaml_zip(
96
+ namespace,
97
+ existing_zip_bytes,
98
+ )
99
+
100
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
101
+ zf.extractall(base_path)
176
102
 
177
103
  def push(
178
104
  self,
@@ -192,14 +192,14 @@ def module__query_service_client(
192
192
  qs_client = QueryServiceClient(uri="query_service:8001")
193
193
  qs_client.query_state = QueryState.RUNNING # type: ignore
194
194
 
195
- def mock_get_columns_for_table(
195
+ async def mock_get_columns_for_table(
196
196
  catalog: str,
197
197
  schema: str,
198
198
  table: str,
199
- engine: Optional[Engine] = None, # pylint: disable=unused-argument
200
199
  request_headers: Optional[ # pylint: disable=unused-argument
201
200
  Dict[str, str]
202
201
  ] = None,
202
+ engine: Optional[Engine] = None, # pylint: disable=unused-argument
203
203
  ) -> List[Column]:
204
204
  return COLUMN_MAPPINGS[f"{catalog}.{schema}.{table}"]
205
205
 
@@ -209,23 +209,27 @@ def module__query_service_client(
209
209
  mock_get_columns_for_table,
210
210
  )
211
211
 
212
- def mock_submit_query(
212
+ async def mock_submit_query(
213
213
  query_create: QueryCreate,
214
214
  request_headers: Optional[ # pylint: disable=unused-argument
215
215
  Dict[str, str]
216
216
  ] = None,
217
217
  ) -> QueryWithResults:
218
- normalized_query = (
219
- query_create.submitted_query.strip()
220
- .replace('"', "")
221
- .replace("\n", "")
222
- .replace(" ", "")
223
- )
224
- # Strip LIMIT clause for matching (allows queries with/without LIMIT to match)
225
- normalized_query = re.sub(r"LIMIT\d+$", "", normalized_query)
218
+ def _normalize(s: str) -> str:
219
+ out = (
220
+ s.strip()
221
+ .replace('"', "")
222
+ .replace("\n", "")
223
+ .replace("\t", "")
224
+ .replace(" ", "")
225
+ )
226
+ return re.sub(r"LIMIT\d+$", "", out)
227
+
228
+ normalized_query = _normalize(query_create.submitted_query)
226
229
 
227
- if normalized_query in QUERY_DATA_MAPPINGS:
228
- results = QUERY_DATA_MAPPINGS[normalized_query]
230
+ normalized_mappings = {_normalize(k): v for k, v in QUERY_DATA_MAPPINGS.items()}
231
+ if normalized_query in normalized_mappings:
232
+ results = normalized_mappings[normalized_query]
229
233
  else:
230
234
  # SQL-agnostic fallback: return a generic successful result.
231
235
  # Client tests should not depend on the exact SQL generated — they
@@ -262,7 +266,7 @@ def module__query_service_client(
262
266
  mock_submit_query,
263
267
  )
264
268
 
265
- def mock_create_view(
269
+ async def mock_create_view(
266
270
  view_name: str,
267
271
  query_create: QueryCreate, # pylint: disable=unused-argument
268
272
  request_headers: Optional[ # pylint: disable=unused-argument
@@ -2,6 +2,7 @@ import importlib.metadata
2
2
  import io
3
3
  from pathlib import Path
4
4
  import time
5
+ import zipfile
5
6
  from unittest import mock
6
7
  import pytest
7
8
  from unittest.mock import MagicMock, patch
@@ -18,126 +19,46 @@ import yaml
18
19
  from rich.console import Console
19
20
 
20
21
 
21
- def test_clean_dict_removes_nones_and_empty():
22
- dirty = {
23
- "a": None,
24
- "b": [],
25
- "c": {},
26
- "d": {"x": None, "y": {"z": []}, "k": "keep"},
27
- "e": [1, 2],
28
- }
29
- cleaned = DeploymentService.clean_dict(dirty)
30
- assert cleaned == {"d": {"k": "keep"}, "e": [1, 2]}
31
-
32
-
33
- def test_filter_node_for_export_removes_columns_without_customizations():
34
- """Columns without meaningful customizations should be removed."""
35
- node = {
36
- "name": "test.node",
37
- "query": "SELECT * FROM foo",
38
- "columns": [
39
- # Should be kept: has custom display_name
40
- {"name": "user_id", "type": "INT", "display_name": "User ID"},
41
- # Should be removed: display_name same as name
42
- {"name": "created_at", "type": "TIMESTAMP", "display_name": "created_at"},
43
- # Should be kept: has attributes
44
- {"name": "id", "type": "BIGINT", "attributes": ["primary_key"]},
45
- # Should be removed: no customizations
46
- {"name": "plain_col", "type": "VARCHAR"},
47
- # Should be kept: has description
48
- {"name": "desc_col", "type": "TEXT", "description": "A useful column"},
49
- ],
50
- }
51
-
52
- filtered = DeploymentService.filter_node_for_export(node)
53
-
54
- # Only columns with customizations should remain
55
- assert len(filtered["columns"]) == 3
56
-
57
- # Type should be excluded from all columns
58
- for col in filtered["columns"]:
59
- assert "type" not in col
60
-
61
- # Check correct columns were kept
62
- col_names = [c["name"] for c in filtered["columns"]]
63
- assert "user_id" in col_names
64
- assert "id" in col_names
65
- assert "desc_col" in col_names
66
- assert "created_at" not in col_names
67
- assert "plain_col" not in col_names
68
-
69
-
70
- def test_filter_node_for_export_removes_columns_key_when_empty():
71
- """If no columns have customizations, the columns key should be removed."""
72
- node = {
73
- "name": "test.node",
74
- "query": "SELECT * FROM foo",
75
- "columns": [
76
- {"name": "a", "type": "INT"},
77
- {"name": "b", "type": "VARCHAR", "display_name": "b"}, # same as name
78
- ],
79
- }
80
-
81
- filtered = DeploymentService.filter_node_for_export(node)
82
-
83
- assert "columns" not in filtered
84
-
22
+ def _make_zip(files: dict[str, str]) -> bytes:
23
+ buf = io.BytesIO()
24
+ with zipfile.ZipFile(buf, "w") as zf:
25
+ for name, content in files.items():
26
+ zf.writestr(name, content)
27
+ return buf.getvalue()
85
28
 
86
- def test_filter_node_for_export_always_removes_columns_for_cubes():
87
- """Cube columns should always be removed - they're inferred from metrics/dimensions."""
88
- node = {
89
- "name": "test.cube",
90
- "node_type": "cube",
91
- "metrics": ["test.metric1", "test.metric2"],
92
- "dimensions": ["test.dim1"],
93
- "columns": [
94
- {"name": "metric1", "type": "BIGINT"},
95
- {"name": "dim1", "type": "VARCHAR", "display_name": "Dimension 1"},
96
- ],
97
- }
98
-
99
- filtered = DeploymentService.filter_node_for_export(node)
100
-
101
- # Columns should be removed regardless of customizations
102
- assert "columns" not in filtered
103
- # Other fields should remain
104
- assert filtered["metrics"] == ["test.metric1", "test.metric2"]
105
- assert filtered["dimensions"] == ["test.dim1"]
106
29
 
107
-
108
- def test_pull_writes_yaml_files(tmp_path):
109
- # fake client returning a minimal deployment spec
30
+ def test_pull_extracts_zip_from_server(tmp_path):
31
+ zip_bytes = _make_zip(
32
+ {
33
+ "dj.yaml": "namespace: foo.bar\n",
34
+ "foo/bar/baz.yaml": "name: foo.bar.baz\nquery: SELECT 1\n",
35
+ },
36
+ )
110
37
  client = MagicMock()
111
- client._export_namespace_spec.return_value = {
112
- "namespace": "foo.bar",
113
- "nodes": [
114
- {"name": "foo.bar.baz", "query": "SELECT 1"},
115
- {"name": "foo.bar.qux", "query": "SELECT 2"},
116
- ],
117
- }
38
+ client._export_namespace_yaml_zip.return_value = zip_bytes
118
39
  svc = DeploymentService(client)
119
40
 
120
41
  svc.pull("foo.bar", tmp_path)
121
42
 
122
- # project-level yaml
123
- project_yaml = yaml.safe_load((tmp_path / "dj.yaml").read_text())
124
- assert project_yaml["namespace"] == "foo.bar"
125
-
126
- # node files
127
- baz_file = tmp_path / "foo" / "bar" / "baz.yaml"
128
- assert baz_file.exists()
129
- assert yaml.safe_load(baz_file.read_text())["query"] == "SELECT 1"
43
+ client._export_namespace_yaml_zip.assert_called_once_with("foo.bar", None)
44
+ assert yaml.safe_load((tmp_path / "dj.yaml").read_text())["namespace"] == "foo.bar"
45
+ assert (tmp_path / "foo" / "bar" / "baz.yaml").exists()
130
46
 
131
- qux_file = tmp_path / "foo" / "bar" / "qux.yaml"
132
- assert qux_file.exists()
133
47
 
134
-
135
- def test_pull_raises_if_target_not_empty(tmp_path):
136
- (tmp_path / "something.txt").write_text("not empty")
48
+ def test_pull_uploads_existing_yaml_files(tmp_path):
49
+ (tmp_path / "old.yaml").write_text("name: ns.old\n")
50
+ updated_zip = _make_zip({"old.yaml": "name: ns.old\nquery: SELECT 2\n"})
137
51
  client = MagicMock()
52
+ client._export_namespace_yaml_zip.return_value = updated_zip
138
53
  svc = DeploymentService(client)
139
- with pytest.raises(DJClientException):
140
- svc.pull("ns", tmp_path)
54
+
55
+ svc.pull("ns", tmp_path)
56
+
57
+ _, call_kwargs = client._export_namespace_yaml_zip.call_args
58
+ existing_bytes = client._export_namespace_yaml_zip.call_args[0][1]
59
+ assert existing_bytes is not None
60
+ with zipfile.ZipFile(io.BytesIO(existing_bytes)) as zf:
61
+ assert "old.yaml" in zf.namelist()
141
62
 
142
63
 
143
64
  def test_print_results_success():
File without changes
File without changes
File without changes
File without changes