dagster-cloud-cli 1.12.16__py2.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 (71) hide show
  1. dagster_cloud_cli/__init__.py +5 -0
  2. dagster_cloud_cli/commands/__init__.py +0 -0
  3. dagster_cloud_cli/commands/branch_deployment/__init__.py +154 -0
  4. dagster_cloud_cli/commands/ci/__init__.py +1207 -0
  5. dagster_cloud_cli/commands/ci/checks.py +135 -0
  6. dagster_cloud_cli/commands/ci/report.py +49 -0
  7. dagster_cloud_cli/commands/ci/state.py +129 -0
  8. dagster_cloud_cli/commands/ci/utils.py +22 -0
  9. dagster_cloud_cli/commands/config.py +250 -0
  10. dagster_cloud_cli/commands/deployment/__init__.py +108 -0
  11. dagster_cloud_cli/commands/deployment/alert_policies/__init__.py +0 -0
  12. dagster_cloud_cli/commands/deployment/alert_policies/commands.py +52 -0
  13. dagster_cloud_cli/commands/integration/__init__.py +6 -0
  14. dagster_cloud_cli/commands/integration/atlan/__init__.py +94 -0
  15. dagster_cloud_cli/commands/job/__init__.py +117 -0
  16. dagster_cloud_cli/commands/metrics.py +114 -0
  17. dagster_cloud_cli/commands/organization/__init__.py +134 -0
  18. dagster_cloud_cli/commands/organization/saml/__init__.py +0 -0
  19. dagster_cloud_cli/commands/organization/saml/commands.py +85 -0
  20. dagster_cloud_cli/commands/run/__init__.py +27 -0
  21. dagster_cloud_cli/commands/serverless/Dockerfile +38 -0
  22. dagster_cloud_cli/commands/serverless/Dockerfile.dagster-cloud-cli +20 -0
  23. dagster_cloud_cli/commands/serverless/__init__.py +560 -0
  24. dagster_cloud_cli/commands/workspace/__init__.py +358 -0
  25. dagster_cloud_cli/config/__init__.py +6 -0
  26. dagster_cloud_cli/config/jinja_template_loader.py +45 -0
  27. dagster_cloud_cli/config/list_merge_strategies.py +77 -0
  28. dagster_cloud_cli/config/loader.py +23 -0
  29. dagster_cloud_cli/config/merger.py +92 -0
  30. dagster_cloud_cli/config/models.py +76 -0
  31. dagster_cloud_cli/config_utils.py +506 -0
  32. dagster_cloud_cli/core/__init__.py +0 -0
  33. dagster_cloud_cli/core/agent_queue.py +3 -0
  34. dagster_cloud_cli/core/alert_types.py +26 -0
  35. dagster_cloud_cli/core/artifacts.py +192 -0
  36. dagster_cloud_cli/core/docker_runner.py +57 -0
  37. dagster_cloud_cli/core/errors.py +32 -0
  38. dagster_cloud_cli/core/graphql_client.py +420 -0
  39. dagster_cloud_cli/core/headers/__init__.py +0 -0
  40. dagster_cloud_cli/core/headers/auth.py +16 -0
  41. dagster_cloud_cli/core/headers/impl.py +31 -0
  42. dagster_cloud_cli/core/headers/versioning/__init__.py +0 -0
  43. dagster_cloud_cli/core/headers/versioning/constants.py +2 -0
  44. dagster_cloud_cli/core/pex_builder/__init__.py +8 -0
  45. dagster_cloud_cli/core/pex_builder/code_location.py +126 -0
  46. dagster_cloud_cli/core/pex_builder/deploy.py +255 -0
  47. dagster_cloud_cli/core/pex_builder/deps.py +495 -0
  48. dagster_cloud_cli/core/pex_builder/git_context.py +29 -0
  49. dagster_cloud_cli/core/pex_builder/github_context.py +178 -0
  50. dagster_cloud_cli/core/pex_builder/gitlab_context.py +30 -0
  51. dagster_cloud_cli/core/pex_builder/parse_workspace.py +39 -0
  52. dagster_cloud_cli/core/pex_builder/pex_registry.py +148 -0
  53. dagster_cloud_cli/core/pex_builder/platforms.py +6105 -0
  54. dagster_cloud_cli/core/pex_builder/selftest.py +14 -0
  55. dagster_cloud_cli/core/pex_builder/source.py +166 -0
  56. dagster_cloud_cli/core/pex_builder/util.py +165 -0
  57. dagster_cloud_cli/core/workspace.py +145 -0
  58. dagster_cloud_cli/docker_utils.py +98 -0
  59. dagster_cloud_cli/entrypoint.py +209 -0
  60. dagster_cloud_cli/gql.py +1025 -0
  61. dagster_cloud_cli/pex_utils.py +180 -0
  62. dagster_cloud_cli/py.typed +0 -0
  63. dagster_cloud_cli/types.py +78 -0
  64. dagster_cloud_cli/ui.py +101 -0
  65. dagster_cloud_cli/utils.py +107 -0
  66. dagster_cloud_cli/version.py +1 -0
  67. dagster_cloud_cli-1.12.16.dist-info/METADATA +31 -0
  68. dagster_cloud_cli-1.12.16.dist-info/RECORD +71 -0
  69. dagster_cloud_cli-1.12.16.dist-info/WHEEL +5 -0
  70. dagster_cloud_cli-1.12.16.dist-info/entry_points.txt +3 -0
  71. dagster_cloud_cli-1.12.16.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,358 @@
1
+ import time
2
+ from collections.abc import Mapping
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from dagster_shared import check
8
+ from dagster_shared.serdes.serdes import deserialize_value
9
+ from typer import Argument, Option, Typer
10
+
11
+ from dagster_cloud_cli import gql, ui
12
+ from dagster_cloud_cli.config_utils import (
13
+ DEFAULT_LOCATION_LOAD_TIMEOUT,
14
+ DEPLOYMENT_CLI_OPTIONS,
15
+ dagster_cloud_options,
16
+ get_location_document,
17
+ )
18
+ from dagster_cloud_cli.core.graphql_client import DagsterCloudGraphQLClient
19
+ from dagster_cloud_cli.core.workspace import CodeLocationDeployData
20
+ from dagster_cloud_cli.utils import add_options
21
+
22
+ DEFAULT_LOCATIONS_YAML_FILENAME = "locations.yaml"
23
+
24
+ app = Typer(help="Manage your Dagster Cloud workspace.")
25
+
26
+
27
+ def _add_or_update_location(
28
+ client: DagsterCloudGraphQLClient,
29
+ location_document: dict[str, Any],
30
+ location_load_timeout: int,
31
+ agent_heartbeat_timeout: int,
32
+ url: str | None = None,
33
+ ) -> None:
34
+ try:
35
+ gql.add_or_update_code_location(client, location_document)
36
+ name = location_document["location_name"]
37
+ ui.print(f"Added or updated location {name}.")
38
+ wait_for_load(
39
+ client,
40
+ [name],
41
+ location_load_timeout=location_load_timeout,
42
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
43
+ url=url,
44
+ )
45
+ except Exception as e:
46
+ raise ui.error(str(e))
47
+
48
+
49
+ @app.command(name="add-location", short_help="Add or update a code location image.")
50
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
51
+ @add_options(DEPLOYMENT_CLI_OPTIONS)
52
+ def add_command(
53
+ api_token: str,
54
+ url: str,
55
+ deployment: str | None,
56
+ location_load_timeout: int,
57
+ agent_heartbeat_timeout: int,
58
+ location: str = Argument(None, help="Code location name."),
59
+ **kwargs,
60
+ ):
61
+ """Add or update the image for a code location in the deployment."""
62
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
63
+ location_document = get_location_document(location, kwargs)
64
+ _add_or_update_location(
65
+ client,
66
+ location_document,
67
+ location_load_timeout=location_load_timeout,
68
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
69
+ url=url,
70
+ )
71
+
72
+
73
+ def list_locations(location_names: list[str]) -> str:
74
+ if len(location_names) == 0:
75
+ return ""
76
+ elif len(location_names) == 1:
77
+ return location_names[0]
78
+ else:
79
+ return f"{', '.join(location_names[:-1])}, and {location_names[-1]}"
80
+
81
+
82
+ @app.command(name="update-location", short_help="Update a code location image.")
83
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
84
+ @add_options(DEPLOYMENT_CLI_OPTIONS)
85
+ def update_command(
86
+ api_token: str,
87
+ url: str,
88
+ deployment: str | None,
89
+ location_load_timeout: int,
90
+ agent_heartbeat_timeout: int,
91
+ location: str = Argument(None, help="Code location name."),
92
+ **kwargs,
93
+ ):
94
+ """Update the image for a code location in a deployment."""
95
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
96
+ location_document = get_location_document(location, kwargs)
97
+ _add_or_update_location(
98
+ client,
99
+ location_document,
100
+ location_load_timeout=location_load_timeout,
101
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
102
+ url=url,
103
+ )
104
+
105
+
106
+ def _format_error(load_error: Mapping[str, Any]):
107
+ result = [
108
+ load_error["message"],
109
+ "".join(load_error["stack"]),
110
+ ]
111
+
112
+ for chain_link in load_error["errorChain"]:
113
+ result.append(
114
+ "The above exception was caused by the following exception:"
115
+ if chain_link["isExplicitLink"]
116
+ else "The above exception occurred during handling of the following exception:"
117
+ )
118
+
119
+ result.extend([chain_link["error"]["message"], "".join(chain_link["error"]["stack"])])
120
+
121
+ return "\n".join(result)
122
+
123
+
124
+ def wait_for_load(
125
+ client,
126
+ locations,
127
+ location_load_timeout=DEFAULT_LOCATION_LOAD_TIMEOUT,
128
+ agent_heartbeat_timeout: int | None = DEFAULT_LOCATION_LOAD_TIMEOUT,
129
+ url: str | None = None,
130
+ ):
131
+ start_time = time.time()
132
+ if url:
133
+ ui.print(f"View the status of your locations at {url}/locations.\n")
134
+ ui.print(f"Waiting for agent to sync changes to {list_locations(locations)}...")
135
+
136
+ if not location_load_timeout and not agent_heartbeat_timeout:
137
+ return
138
+
139
+ has_agent_heartbeat = False
140
+ iterations = 0
141
+ while True:
142
+ if not has_agent_heartbeat:
143
+ if agent_heartbeat_timeout and time.time() - start_time > agent_heartbeat_timeout:
144
+ raise ui.error(
145
+ "No Dagster Cloud agent is actively heartbeating. Make sure that you have a"
146
+ " Dagster Cloud agent running."
147
+ )
148
+ try:
149
+ agents = gql.fetch_agent_status(client)
150
+ except Exception as e:
151
+ raise ui.error("Unable to query agent status: " + str(e))
152
+
153
+ has_agent_heartbeat = any(a["status"] == "RUNNING" for a in agents)
154
+
155
+ if time.time() - start_time > location_load_timeout:
156
+ raise ui.error("Timed out waiting for location data to update.")
157
+
158
+ try:
159
+ nodes = gql.fetch_code_locations(client)
160
+ except Exception as e:
161
+ raise ui.error(str(e))
162
+
163
+ nodes_by_location = {node["name"]: node for node in nodes}
164
+
165
+ if all(
166
+ location in nodes_by_location
167
+ and nodes_by_location[location].get("loadStatus") == "LOADED"
168
+ for location in locations
169
+ ):
170
+ error_locations = [
171
+ location
172
+ for location in locations
173
+ if "locationOrLoadError" in nodes_by_location[location]
174
+ and nodes_by_location[location]["locationOrLoadError"]["__typename"]
175
+ == "PythonError"
176
+ ]
177
+
178
+ if error_locations:
179
+ error_string = (
180
+ "Some locations failed to load after being synced by the agent:\n"
181
+ + "\n".join(
182
+ [
183
+ f"Error loading {error_location}:\n"
184
+ f"{_format_error(nodes_by_location[error_location]['locationOrLoadError'])}"
185
+ for error_location in error_locations
186
+ ]
187
+ )
188
+ )
189
+ raise ui.error(error_string)
190
+ else:
191
+ ui.print(
192
+ f"Agent synced changes to {list_locations(locations)}. Changes should now be"
193
+ " visible in Dagster Cloud."
194
+ )
195
+ break
196
+
197
+ time.sleep(3)
198
+ iterations += 1
199
+
200
+ if iterations % 3 == 0:
201
+ ui.print(
202
+ f"Still waiting for agent to sync changes to {list_locations(locations)}. This can"
203
+ " take a few minutes."
204
+ )
205
+
206
+
207
+ @app.command(
208
+ name="delete-location",
209
+ )
210
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
211
+ def delete_command(
212
+ api_token: str,
213
+ url: str,
214
+ deployment: str | None,
215
+ location: str = Argument(..., help="Code location name."),
216
+ ):
217
+ """Delete a code location from the deployment."""
218
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
219
+ try:
220
+ gql.delete_code_location(client, location)
221
+ ui.print(f"Deleted location {location}.")
222
+ except Exception as e:
223
+ raise ui.error(str(e))
224
+
225
+
226
+ @app.command(
227
+ name="list",
228
+ )
229
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
230
+ def list_command(
231
+ url: str,
232
+ deployment: str | None,
233
+ api_token: str,
234
+ ):
235
+ """List code locations in the deployment."""
236
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
237
+ execute_list_command(client)
238
+
239
+
240
+ def execute_list_command(client):
241
+ list_res = gql.fetch_workspace_entries(client)
242
+
243
+ ui.print("Listing locations...")
244
+
245
+ for location in list_res:
246
+ metadata = deserialize_value(
247
+ location["serializedDeploymentMetadata"], CodeLocationDeployData
248
+ )
249
+
250
+ location_desc = [location["locationName"]]
251
+ if metadata.python_file:
252
+ location_desc.append(f"File: {metadata.python_file}")
253
+ if metadata.package_name:
254
+ location_desc.append(f"Package: {metadata.package_name}")
255
+ if metadata.image:
256
+ location_desc.append(f"Image: {metadata.image}")
257
+
258
+ ui.print("\t".join(location_desc))
259
+
260
+
261
+ @app.command(name="pull")
262
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
263
+ def pull_command(
264
+ url: str,
265
+ api_token: str,
266
+ deployment: str | None,
267
+ ):
268
+ """Retrieve code location definitions as a workspace.yaml file."""
269
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
270
+ document = gql.fetch_locations_as_document(client)
271
+ ui.print_yaml(document or {})
272
+
273
+
274
+ @app.command(
275
+ name="sync",
276
+ short_help="Sync deployment code locations with a workspace.yaml file.",
277
+ )
278
+ @dagster_cloud_options(allow_empty=True, requires_url=True)
279
+ def sync_command(
280
+ url: str,
281
+ api_token: str,
282
+ deployment: str | None,
283
+ location_load_timeout: int,
284
+ agent_heartbeat_timeout: int,
285
+ workspace: Path = Option(
286
+ "workspace.yaml",
287
+ "--workspace",
288
+ "-w",
289
+ exists=True,
290
+ help="Path to workspace file.",
291
+ ),
292
+ ):
293
+ """Sync the workspace with the contents of a workspace.yaml file."""
294
+ with gql.graphql_client_from_url(url, api_token, deployment_name=deployment) as client:
295
+ execute_sync_command(
296
+ client,
297
+ workspace,
298
+ location_load_timeout=location_load_timeout,
299
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
300
+ url=url,
301
+ )
302
+
303
+
304
+ def format_workspace_config(workspace_config) -> dict[str, Any]:
305
+ """Ensures the input workspace config is in the modern format, migrating an input
306
+ in the old format if need be.
307
+ """
308
+ check.dict_param(workspace_config, "workspace_config")
309
+
310
+ if isinstance(workspace_config.get("locations"), dict):
311
+ # Convert legacy formatted locations to modern format
312
+ updated_locations = []
313
+ for name, location in workspace_config["locations"].items():
314
+ new_location = {
315
+ k: v
316
+ for k, v in location.items()
317
+ if k
318
+ not in ("python_file", "package_name", "module_name", "autoload_defs_module_name")
319
+ }
320
+ new_location["code_source"] = {}
321
+ if "python_file" in location:
322
+ new_location["code_source"]["python_file"] = location["python_file"]
323
+ if "package_name" in location:
324
+ new_location["code_source"]["package_name"] = location["package_name"]
325
+ if "module_name" in location:
326
+ new_location["code_source"]["module_name"] = location["module_name"]
327
+ if "autoload_defs_module_name" in location:
328
+ new_location["code_source"]["autoload_defs_module_name"] = location[
329
+ "autoload_defs_module_name"
330
+ ]
331
+
332
+ new_location["location_name"] = name
333
+ updated_locations.append(new_location)
334
+ return {"locations": updated_locations}
335
+ else:
336
+ check.is_list(workspace_config.get("locations"))
337
+ return workspace_config
338
+
339
+
340
+ def execute_sync_command(
341
+ client, workspace, location_load_timeout, agent_heartbeat_timeout, url: str | None = None
342
+ ):
343
+ with open(str(workspace), encoding="utf8") as f:
344
+ config = yaml.load(f.read(), Loader=yaml.SafeLoader)
345
+ processed_config = format_workspace_config(config)
346
+
347
+ try:
348
+ locations = gql.reconcile_code_locations(client, processed_config)
349
+ ui.print(f"Synced locations: {', '.join(locations)}")
350
+ wait_for_load(
351
+ client,
352
+ locations,
353
+ location_load_timeout=location_load_timeout,
354
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
355
+ url=url,
356
+ )
357
+ except Exception as e:
358
+ raise ui.error(str(e))
@@ -0,0 +1,6 @@
1
+ from dagster_cloud_cli.config.jinja_template_loader import (
2
+ JinjaTemplateLoader as JinjaTemplateLoader,
3
+ )
4
+ from dagster_cloud_cli.config.merger import (
5
+ DagsterCloudConfigDefaultsMerger as DagsterCloudConfigDefaultsMerger,
6
+ )
@@ -0,0 +1,45 @@
1
+ from collections.abc import Mapping
2
+ from os import getenv
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ class JinjaTemplateLoader:
10
+ """Reads and render a file with jinja2 templating support and provide additional templating functionality.
11
+
12
+ Ref: https://jinja.palletsprojects.com/en/stable/
13
+
14
+ In addition to the built-in global functions and filters, this class provides the following:
15
+
16
+ Custom jinja2 global function(s):
17
+ - get_env(name: str, default: str = "") -> str
18
+ Get environment variable if present or return a default value.
19
+
20
+ Custom jinja2 filter(s):
21
+ - to_yaml(input: Any, **kwargs) -> str
22
+ Renders a value into its yaml representation.
23
+ """
24
+
25
+ @staticmethod
26
+ def to_yaml(value: Any, **kwargs) -> str:
27
+ return yaml.dump(value, **kwargs)
28
+
29
+ @staticmethod
30
+ def get_env(name: str, default: str = "") -> str:
31
+ return getenv(name, default)
32
+
33
+ def render(self, filepath: str, context: Mapping[str, Any]) -> str:
34
+ # defer for import performance
35
+ from jinja2 import Environment, FileSystemLoader
36
+
37
+ path = Path(filepath)
38
+ template_path = path.resolve().parent
39
+ filename = path.name
40
+
41
+ env = Environment(loader=FileSystemLoader(template_path))
42
+ env.filters["to_yaml"] = self.to_yaml
43
+ env.globals["get_env"] = self.get_env
44
+ template = env.get_template(filename)
45
+ return template.render(context)
@@ -0,0 +1,77 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from dagster_shared import check
5
+ from dagster_shared.utils.hash import hash_collection
6
+
7
+ ListMergeFunction = Callable[[list[Any], list[Any]], list[Any]]
8
+
9
+
10
+ def replace(from_list: list[Any], _: list[Any]) -> list[Any]:
11
+ return from_list
12
+
13
+
14
+ def append(from_list: list[Any], onto_list: list[Any]) -> list[Any]:
15
+ return onto_list + from_list
16
+
17
+
18
+ def deduplicate(from_list: list[Any], onto_list: list[Any]) -> list[Any]:
19
+ return _deduplicate_list(onto_list + from_list)
20
+
21
+
22
+ def get_list_merger_for_key_value_separated_strings(separator: str) -> ListMergeFunction:
23
+ def func(from_list: list[Any], onto_list: list[Any]) -> list[Any]:
24
+ fl = check.list_param(from_list, "from_list", of_type=str)
25
+ ol = check.list_param(onto_list, "onto_list", of_type=str)
26
+ return _merge_key_value_separated_strings(fl, ol, separator)
27
+
28
+ return func
29
+
30
+
31
+ def get_list_merger_for_identifiable_dicts(name_key: str) -> ListMergeFunction:
32
+ def func(from_list: list[Any], onto_list: list[Any]) -> list[Any]:
33
+ fl = check.list_param(from_list, "from_list", of_type=dict)
34
+ ol = check.list_param(onto_list, "onto_list", of_type=dict)
35
+ return _merge_object_list_by_identity(name_key, fl, ol)
36
+
37
+ return func
38
+
39
+
40
+ def _deduplicate_list(lst: list[Any]) -> list[Any]:
41
+ result = []
42
+ hashes = set()
43
+ for value in lst:
44
+ value_hash = hash_collection(value) if isinstance(value, (list, dict)) else hash(value)
45
+ if value_hash not in hashes:
46
+ hashes.add(value_hash)
47
+ result.append(value)
48
+ return result
49
+
50
+
51
+ def _merge_key_value_separated_strings(
52
+ from_list: list[str], onto_list: list[str], separator: str
53
+ ) -> list[str]:
54
+ from_items = [item.split(separator)[0] for item in from_list]
55
+ original_items = []
56
+
57
+ for item in onto_list:
58
+ parts = item.split(separator)
59
+ if len(parts) > 2:
60
+ raise ValueError(f"Invalid key-value pair: {item}")
61
+
62
+ if parts[0] not in from_items:
63
+ original_items.append(item)
64
+
65
+ return original_items + from_list
66
+
67
+
68
+ def _merge_object_list_by_identity(
69
+ name_key: str, from_list: list[dict[str, Any]], onto_list: list[dict[str, Any]]
70
+ ) -> list[dict[str, Any]]:
71
+ onto_items = {item[name_key]: item for item in onto_list}
72
+ from_items = {item[name_key]: item for item in from_list}
73
+
74
+ merged_items = {
75
+ key: item for key, item in onto_items.items() if key not in from_items
76
+ } | from_items
77
+ return list(merged_items.values())
@@ -0,0 +1,23 @@
1
+ from typing import Any
2
+
3
+ from dagster_cloud_cli.config.jinja_template_loader import JinjaTemplateLoader
4
+ from dagster_cloud_cli.config.merger import DagsterCloudConfigDefaultsMerger
5
+ from dagster_cloud_cli.config.models import ProcessedDagsterCloudConfig, load_dagster_cloud_yaml
6
+
7
+
8
+ class DagsterCloudLocationsConfigLoader:
9
+ """Load a Dagster Cloud config from a template, with context values, merge in defaults to code location
10
+ and return a ProcessedDagsterCloudConfig object.
11
+ """
12
+
13
+ @staticmethod
14
+ def load_from_template(
15
+ template_file: str, context: dict[str, Any]
16
+ ) -> ProcessedDagsterCloudConfig:
17
+ template_loader = JinjaTemplateLoader()
18
+ template = template_loader.render(template_file, context)
19
+
20
+ dagster_cloud_config = load_dagster_cloud_yaml(template)
21
+
22
+ config_merger = DagsterCloudConfigDefaultsMerger()
23
+ return config_merger.process(dagster_cloud_config)
@@ -0,0 +1,92 @@
1
+ from copy import deepcopy
2
+ from typing import Any
3
+
4
+ from dagster_shared import check
5
+ from dagster_shared.utils import remove_none_recursively
6
+
7
+ from dagster_cloud_cli.config.list_merge_strategies import (
8
+ deduplicate,
9
+ get_list_merger_for_identifiable_dicts,
10
+ get_list_merger_for_key_value_separated_strings,
11
+ replace,
12
+ )
13
+ from dagster_cloud_cli.config.models import DagsterCloudYaml, Location, ProcessedDagsterCloudConfig
14
+
15
+
16
+ class DagsterCloudConfigDefaultsMerger:
17
+ """Transform the DagsterCloudYaml provided configuration input and return the processed config resulting
18
+ from merging the defaults with each location.
19
+ """
20
+
21
+ def __init__(self):
22
+ equal_sign_list_merger = get_list_merger_for_key_value_separated_strings("=")
23
+ named_object_list_merger = get_list_merger_for_identifiable_dicts("name")
24
+ keyed_object_list_merger = get_list_merger_for_identifiable_dicts("key")
25
+
26
+ self._default_list_strategy = deduplicate
27
+ self._list_strategy_exceptions = {
28
+ "container_context.docker.env_vars": equal_sign_list_merger,
29
+ "container_context.ecs.env_vars": equal_sign_list_merger,
30
+ "container_context.ecs.run_sidecar_containers": named_object_list_merger,
31
+ "container_context.ecs.secrets": named_object_list_merger,
32
+ "container_context.ecs.server_sidecar_containers": named_object_list_merger,
33
+ "container_context.ecs.volumes": named_object_list_merger,
34
+ "container_context.ecs.run_ecs_tags": keyed_object_list_merger,
35
+ "container_context.ecs.server_ecs_tags": keyed_object_list_merger,
36
+ "container_context.k8s.env_vars": equal_sign_list_merger,
37
+ "container_context.k8s.image_pull_secrets": named_object_list_merger,
38
+ "container_context.k8s.run_k8s_config.container_config.args": replace,
39
+ "container_context.k8s.run_k8s_config.container_config.command": replace,
40
+ "container_context.k8s.server_k8s_config.container_config.args": replace,
41
+ "container_context.k8s.server_k8s_config.container_config.command": replace,
42
+ "container_context.k8s.volumes": named_object_list_merger,
43
+ "container_context.k8s.volume_mounts": named_object_list_merger,
44
+ }
45
+
46
+ def process(self, source_config: DagsterCloudYaml) -> ProcessedDagsterCloudConfig:
47
+ if source_config.defaults is None:
48
+ return ProcessedDagsterCloudConfig(locations=source_config.locations)
49
+
50
+ source_dict = source_config.model_dump()
51
+ return self.process_dict(source_dict)
52
+
53
+ def process_dict(self, source_dict: dict[str, Any]) -> ProcessedDagsterCloudConfig:
54
+ processed_config = ProcessedDagsterCloudConfig(locations=[])
55
+
56
+ defaults = remove_none_recursively(source_dict.get("defaults", {}))
57
+ for location in source_dict.get("locations", []):
58
+ location_data = remove_none_recursively(location)
59
+ if not defaults:
60
+ processed_config.locations.append(Location.model_validate(location_data))
61
+ continue
62
+
63
+ transformed_location = self.merge(defaults, location_data)
64
+ processed_config.locations.append(Location.model_validate(transformed_location))
65
+
66
+ return processed_config
67
+
68
+ def merge(self, onto_dict: dict[str, Any], from_dict: dict[str, Any]):
69
+ dest = deepcopy(onto_dict)
70
+ return self._merge_dict("", dest, from_dict)
71
+
72
+ def _merge_dict(self, parent_key: str, onto_dict: dict[str, Any], from_dict: dict[str, Any]):
73
+ for key, value in from_dict.items():
74
+ key_path = ".".join([parent_key, key]) if parent_key else key
75
+ if key in onto_dict and onto_dict[key] is not None:
76
+ if isinstance(value, dict):
77
+ check.invariant(isinstance(onto_dict[key], dict))
78
+ onto_dict[key] = self._merge_dict(key_path, onto_dict[key], value)
79
+ elif isinstance(value, list):
80
+ check.invariant(isinstance(onto_dict[key], list))
81
+ onto_dict[key] = self._merge_list(key_path, value, onto_dict[key])
82
+ else:
83
+ onto_dict[key] = value
84
+ else:
85
+ onto_dict[key] = value
86
+ return onto_dict
87
+
88
+ def _merge_list(self, key_path: str, from_list: list[Any], onto_list: list[Any]) -> list[Any]:
89
+ if key_path in self._list_strategy_exceptions:
90
+ return self._list_strategy_exceptions[key_path](from_list, onto_list)
91
+
92
+ return self._default_list_strategy(from_list, onto_list)
@@ -0,0 +1,76 @@
1
+ """Use a pydantic definition to validate dagster_cloud.yaml."""
2
+
3
+ from typing import Any
4
+
5
+ import yaml
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+
9
+ class CodeSource(BaseModel, extra="forbid"):
10
+ package_name: str | None = None
11
+ module_name: str | None = None
12
+ python_file: str | None = None
13
+ autoload_defs_module_name: str | None = None
14
+
15
+ @model_validator(mode="before")
16
+ def exactly_one_source_defined(cls, values: dict[str, str | None]) -> dict[str, str | None]:
17
+ defined = [key for key, value in values.items() if value]
18
+ if len(defined) > 1:
19
+ raise ValueError(
20
+ "only one of the following fields should be defined: " + ", ".join(defined)
21
+ )
22
+ elif not defined:
23
+ raise ValueError(
24
+ "one of package_name, module_name, python_file, or autoload_defs_module_name must be specified"
25
+ )
26
+ return values
27
+
28
+
29
+ class Build(BaseModel, extra="forbid"):
30
+ directory: str | None = None
31
+ registry: str | None = None
32
+
33
+
34
+ class Location(BaseModel, extra="forbid"):
35
+ location_name: str
36
+ code_source: CodeSource | None = None
37
+ build: Build | None = None
38
+ working_directory: str | None = None
39
+ image: str | None = None
40
+ executable_path: str | None = None
41
+ attribute: str | None = None
42
+ container_context: dict[str, Any] | None = None
43
+ agent_queue: str | None = None
44
+
45
+
46
+ class LocationDefaults(BaseModel, extra="forbid"):
47
+ build: Build | None = None
48
+ working_directory: str | None = None
49
+ image: str | None = None
50
+ executable_path: str | None = None
51
+ container_context: dict[str, Any] | None = None
52
+ agent_queue: str | None = None
53
+
54
+
55
+ class DagsterCloudYaml(BaseModel, extra="forbid"):
56
+ defaults: LocationDefaults | None = Field(
57
+ description="Default values for code locations", default=None
58
+ )
59
+ locations: list[Location] = Field(description="List of code locations")
60
+
61
+ @field_validator("locations")
62
+ def no_duplicate_names(cls, v: list[Location]) -> list[Location]:
63
+ names = set()
64
+ for location in v:
65
+ if location.location_name in names:
66
+ raise ValueError(f"duplicate location name: {location.location_name}")
67
+ names.add(location.location_name)
68
+ return v
69
+
70
+
71
+ class ProcessedDagsterCloudConfig(BaseModel, extra="forbid"):
72
+ locations: list[Location] = Field(description="List of code locations")
73
+
74
+
75
+ def load_dagster_cloud_yaml(text) -> DagsterCloudYaml:
76
+ return DagsterCloudYaml.model_validate(yaml.safe_load(text))