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.
- dagster_cloud_cli/__init__.py +5 -0
- dagster_cloud_cli/commands/__init__.py +0 -0
- dagster_cloud_cli/commands/branch_deployment/__init__.py +154 -0
- dagster_cloud_cli/commands/ci/__init__.py +1207 -0
- dagster_cloud_cli/commands/ci/checks.py +135 -0
- dagster_cloud_cli/commands/ci/report.py +49 -0
- dagster_cloud_cli/commands/ci/state.py +129 -0
- dagster_cloud_cli/commands/ci/utils.py +22 -0
- dagster_cloud_cli/commands/config.py +250 -0
- dagster_cloud_cli/commands/deployment/__init__.py +108 -0
- dagster_cloud_cli/commands/deployment/alert_policies/__init__.py +0 -0
- dagster_cloud_cli/commands/deployment/alert_policies/commands.py +52 -0
- dagster_cloud_cli/commands/integration/__init__.py +6 -0
- dagster_cloud_cli/commands/integration/atlan/__init__.py +94 -0
- dagster_cloud_cli/commands/job/__init__.py +117 -0
- dagster_cloud_cli/commands/metrics.py +114 -0
- dagster_cloud_cli/commands/organization/__init__.py +134 -0
- dagster_cloud_cli/commands/organization/saml/__init__.py +0 -0
- dagster_cloud_cli/commands/organization/saml/commands.py +85 -0
- dagster_cloud_cli/commands/run/__init__.py +27 -0
- dagster_cloud_cli/commands/serverless/Dockerfile +38 -0
- dagster_cloud_cli/commands/serverless/Dockerfile.dagster-cloud-cli +20 -0
- dagster_cloud_cli/commands/serverless/__init__.py +560 -0
- dagster_cloud_cli/commands/workspace/__init__.py +358 -0
- dagster_cloud_cli/config/__init__.py +6 -0
- dagster_cloud_cli/config/jinja_template_loader.py +45 -0
- dagster_cloud_cli/config/list_merge_strategies.py +77 -0
- dagster_cloud_cli/config/loader.py +23 -0
- dagster_cloud_cli/config/merger.py +92 -0
- dagster_cloud_cli/config/models.py +76 -0
- dagster_cloud_cli/config_utils.py +506 -0
- dagster_cloud_cli/core/__init__.py +0 -0
- dagster_cloud_cli/core/agent_queue.py +3 -0
- dagster_cloud_cli/core/alert_types.py +26 -0
- dagster_cloud_cli/core/artifacts.py +192 -0
- dagster_cloud_cli/core/docker_runner.py +57 -0
- dagster_cloud_cli/core/errors.py +32 -0
- dagster_cloud_cli/core/graphql_client.py +420 -0
- dagster_cloud_cli/core/headers/__init__.py +0 -0
- dagster_cloud_cli/core/headers/auth.py +16 -0
- dagster_cloud_cli/core/headers/impl.py +31 -0
- dagster_cloud_cli/core/headers/versioning/__init__.py +0 -0
- dagster_cloud_cli/core/headers/versioning/constants.py +2 -0
- dagster_cloud_cli/core/pex_builder/__init__.py +8 -0
- dagster_cloud_cli/core/pex_builder/code_location.py +126 -0
- dagster_cloud_cli/core/pex_builder/deploy.py +255 -0
- dagster_cloud_cli/core/pex_builder/deps.py +495 -0
- dagster_cloud_cli/core/pex_builder/git_context.py +29 -0
- dagster_cloud_cli/core/pex_builder/github_context.py +178 -0
- dagster_cloud_cli/core/pex_builder/gitlab_context.py +30 -0
- dagster_cloud_cli/core/pex_builder/parse_workspace.py +39 -0
- dagster_cloud_cli/core/pex_builder/pex_registry.py +148 -0
- dagster_cloud_cli/core/pex_builder/platforms.py +6105 -0
- dagster_cloud_cli/core/pex_builder/selftest.py +14 -0
- dagster_cloud_cli/core/pex_builder/source.py +166 -0
- dagster_cloud_cli/core/pex_builder/util.py +165 -0
- dagster_cloud_cli/core/workspace.py +145 -0
- dagster_cloud_cli/docker_utils.py +98 -0
- dagster_cloud_cli/entrypoint.py +209 -0
- dagster_cloud_cli/gql.py +1025 -0
- dagster_cloud_cli/pex_utils.py +180 -0
- dagster_cloud_cli/py.typed +0 -0
- dagster_cloud_cli/types.py +78 -0
- dagster_cloud_cli/ui.py +101 -0
- dagster_cloud_cli/utils.py +107 -0
- dagster_cloud_cli/version.py +1 -0
- dagster_cloud_cli-1.12.16.dist-info/METADATA +31 -0
- dagster_cloud_cli-1.12.16.dist-info/RECORD +71 -0
- dagster_cloud_cli-1.12.16.dist-info/WHEEL +5 -0
- dagster_cloud_cli-1.12.16.dist-info/entry_points.txt +3 -0
- 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,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))
|