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,1207 @@
1
+ # CI/CD agnostic commands that work with the current CI/CD system
2
+ import json
3
+ import logging
4
+ import os
5
+ import pathlib
6
+ import shutil
7
+ import sys
8
+ from collections import Counter
9
+ from enum import Enum
10
+ from typing import Annotated, Any, cast
11
+
12
+ import click
13
+ import typer
14
+ import yaml
15
+ from dagster_shared import check
16
+ from dagster_shared.utils import remove_none_recursively
17
+ from jinja2 import TemplateSyntaxError
18
+ from typer import Typer
19
+
20
+ from dagster_cloud_cli import docker_utils, gql, pex_utils, ui
21
+ from dagster_cloud_cli.commands import metrics
22
+ from dagster_cloud_cli.commands.ci import checks, report, state, utils
23
+ from dagster_cloud_cli.commands.workspace import wait_for_load
24
+ from dagster_cloud_cli.config import DagsterCloudConfigDefaultsMerger, JinjaTemplateLoader
25
+ from dagster_cloud_cli.config.models import load_dagster_cloud_yaml
26
+ from dagster_cloud_cli.config_utils import (
27
+ DAGSTER_ENV_OPTION,
28
+ LOCATION_LOAD_TIMEOUT_OPTION,
29
+ ORGANIZATION_OPTION,
30
+ URL_ENV_VAR_NAME,
31
+ dagster_cloud_options,
32
+ get_agent_heartbeat_timeout_option,
33
+ get_location_document,
34
+ get_org_url,
35
+ get_user_token,
36
+ )
37
+ from dagster_cloud_cli.core import pex_builder
38
+ from dagster_cloud_cli.core.artifacts import (
39
+ download_organization_artifact,
40
+ upload_organization_artifact,
41
+ )
42
+ from dagster_cloud_cli.core.pex_builder import (
43
+ code_location,
44
+ deps,
45
+ github_context,
46
+ gitlab_context,
47
+ parse_workspace,
48
+ )
49
+ from dagster_cloud_cli.gql import DagsterPlusDeploymentAgentType
50
+ from dagster_cloud_cli.types import CliEventTags, CliEventType, SnapshotBaseDeploymentCondition
51
+ from dagster_cloud_cli.utils import DEFAULT_PYTHON_VERSION
52
+
53
+ app = Typer(help="Commands for deploying code to Dagster+ from any CI/CD environment")
54
+
55
+ DEFAULT_SERVERLESS_AGENT_HEARTBEAT_TIMEOUT = 900
56
+ DEFAULT_HYBRID_AGENT_HEARTBEAT_TIMEOUT = 120
57
+
58
+
59
+ @app.callback()
60
+ def main():
61
+ logging.basicConfig(
62
+ level=logging.getLevelName(os.getenv("DAGSTER_CLOUD_CLI_LOG_LEVEL", "WARNING"))
63
+ )
64
+
65
+
66
+ @app.command(help="Print json information about current CI/CD environment")
67
+ def inspect(project_dir: str):
68
+ project_dir = os.path.abspath(project_dir)
69
+ source = metrics.get_source()
70
+ info = {"source": str(source), "project-dir": project_dir}
71
+ if source == CliEventTags.source.github:
72
+ info.update(load_github_info(project_dir))
73
+
74
+
75
+ def load_github_info(project_dir: str) -> dict[str, Any]:
76
+ event = github_context.get_github_event(project_dir)
77
+ return {
78
+ "git-url": event.commit_url,
79
+ "commit-hash": event.github_sha,
80
+ }
81
+
82
+
83
+ @app.command(
84
+ help=(
85
+ "Print the branch deployment name (or nothing) for the current context. Creates a new"
86
+ " branch deployment if necessary. Requires DAGSTER_CLOUD_ORGANIZATION and"
87
+ " DAGSTER_CLOUD_API_TOKEN environment variables."
88
+ )
89
+ )
90
+ def branch_deployment(
91
+ project_dir: str,
92
+ organization: str | None = ORGANIZATION_OPTION,
93
+ dagster_env: str | None = DAGSTER_ENV_OPTION,
94
+ mark_closed: bool = False,
95
+ read_only: bool = False,
96
+ base_deployment_name: str | None = None,
97
+ snapshot_base_condition: SnapshotBaseDeploymentCondition | None = None,
98
+ ):
99
+ try:
100
+ if organization:
101
+ url = get_org_url(organization, dagster_env)
102
+ else:
103
+ url = os.environ[URL_ENV_VAR_NAME]
104
+
105
+ if read_only:
106
+ print(get_branch_deployment_name_from_context(url, project_dir)) # noqa: T201
107
+ return
108
+
109
+ print( # noqa: T201
110
+ create_or_update_deployment_from_context(
111
+ url,
112
+ project_dir,
113
+ mark_closed,
114
+ base_deployment_name=base_deployment_name,
115
+ snapshot_base_condition=snapshot_base_condition,
116
+ require_branch_deployment=True,
117
+ )
118
+ )
119
+ except ValueError as err:
120
+ ui.error(
121
+ f"cannot determine branch deployment: {err}",
122
+ )
123
+ sys.exit(1)
124
+
125
+
126
+ def create_or_update_deployment_from_context(
127
+ url,
128
+ project_dir: str,
129
+ mark_closed: bool,
130
+ base_deployment_name: str | None,
131
+ snapshot_base_condition: SnapshotBaseDeploymentCondition | None,
132
+ require_branch_deployment: bool = False,
133
+ ) -> str | None:
134
+ source = metrics.get_source()
135
+ api_token = check.not_none(get_user_token())
136
+ if source == CliEventTags.source.github:
137
+ event = github_context.get_github_event(project_dir)
138
+ deployment_name = code_location.create_or_update_branch_deployment_from_github_context(
139
+ url,
140
+ api_token,
141
+ event,
142
+ mark_closed,
143
+ base_deployment_name=base_deployment_name,
144
+ snapshot_base_condition=snapshot_base_condition,
145
+ )
146
+ if not deployment_name:
147
+ raise ValueError(
148
+ f"could not determine branch deployment for PR {event.pull_request_id}"
149
+ )
150
+
151
+ return deployment_name
152
+ elif source == CliEventTags.source.gitlab:
153
+ event = gitlab_context.get_gitlab_event(project_dir)
154
+ if not event.merge_request_iid:
155
+ raise ValueError("no merge request id")
156
+ with gql.graphql_client_from_url(url, api_token) as client:
157
+ deployment_name = gql.create_or_update_branch_deployment(
158
+ client,
159
+ repo_name=event.project_name,
160
+ branch_name=event.branch_name,
161
+ commit_hash=event.commit_sha,
162
+ timestamp=float(event.git_metadata.timestamp),
163
+ branch_url=event.branch_url,
164
+ pull_request_url=event.merge_request_url,
165
+ pull_request_status=("CLOSED" if mark_closed else "OPEN"),
166
+ pull_request_number=event.merge_request_iid,
167
+ author_name=event.git_metadata.name,
168
+ author_email=event.git_metadata.email,
169
+ commit_message=event.git_metadata.message,
170
+ base_deployment_name=base_deployment_name,
171
+ snapshot_base_condition=snapshot_base_condition,
172
+ )
173
+
174
+ return deployment_name
175
+
176
+ elif source == CliEventTags.source.cli:
177
+ if require_branch_deployment:
178
+ deployment_name = (
179
+ code_location.create_or_update_branch_deployment_from_local_git_context(
180
+ url,
181
+ api_token,
182
+ project_dir,
183
+ mark_closed,
184
+ base_deployment_name=base_deployment_name,
185
+ snapshot_base_condition=snapshot_base_condition,
186
+ )
187
+ )
188
+ if not deployment_name:
189
+ raise Exception(
190
+ "Could not determine branch deployment to use from local git context"
191
+ )
192
+
193
+ return deployment_name
194
+ else:
195
+ return None
196
+ else:
197
+ raise ValueError(f"unsupported for {source}")
198
+
199
+
200
+ def get_branch_deployment_name_from_context(url, project_dir: str) -> str | None:
201
+ source = metrics.get_source()
202
+ api_token = check.not_none(get_user_token())
203
+ if source == CliEventTags.source.github:
204
+ event = github_context.get_github_event(project_dir)
205
+ if not event.branch_name:
206
+ return None
207
+ with gql.graphql_client_from_url(url, api_token) as client:
208
+ return gql.get_branch_deployment_name(
209
+ client,
210
+ repo_name=event.repo_name,
211
+ branch_name=event.branch_name,
212
+ )
213
+ elif source == CliEventTags.source.gitlab:
214
+ event = gitlab_context.get_gitlab_event(project_dir)
215
+ with gql.graphql_client_from_url(url, api_token) as client:
216
+ return gql.get_branch_deployment_name(
217
+ client,
218
+ repo_name=event.project_name,
219
+ branch_name=event.branch_name,
220
+ )
221
+ elif source == CliEventTags.source.cli:
222
+ branch_name = code_location.get_local_branch_name(project_dir)
223
+
224
+ if not branch_name:
225
+ raise Exception("Could not determine branch name from local git context")
226
+
227
+ # Extract just the org/repo part from the git URL
228
+ repo_name = code_location.get_local_repo_name(project_dir)
229
+
230
+ if not repo_name:
231
+ raise Exception("Could not determine repo name from local git context")
232
+
233
+ with gql.graphql_client_from_url(url, api_token) as client:
234
+ return gql.get_branch_deployment_name(
235
+ client,
236
+ repo_name=repo_name,
237
+ branch_name=branch_name,
238
+ )
239
+ else:
240
+ raise ValueError(f"unsupported for {source}")
241
+
242
+
243
+ @app.command(name="check", help="Validate configuration")
244
+ def check_command(
245
+ organization: str | None = ORGANIZATION_OPTION,
246
+ dagster_env: str | None = DAGSTER_ENV_OPTION,
247
+ project_dir: str = typer.Option("."),
248
+ dagster_cloud_yaml_path: str = "dagster_cloud.yaml",
249
+ dagster_cloud_yaml_check: checks.Check = typer.Option("error"),
250
+ dagster_cloud_connect_check: checks.Check = typer.Option("error"),
251
+ ):
252
+ ui.warn(
253
+ "The 'dagster-cloud ci check' command is deprecated and will be removed in a future release. "
254
+ "Use 'dg plus deploy start' instead, which validates configuration during deployment initialization."
255
+ )
256
+
257
+ project_path = pathlib.Path(project_dir)
258
+
259
+ verdicts = []
260
+ if dagster_cloud_yaml_check != checks.Check.skip:
261
+ yaml_path = project_path / dagster_cloud_yaml_path
262
+ result = checks.check_dagster_cloud_yaml(yaml_path)
263
+ verdicts.append(
264
+ checks.handle_result(
265
+ result,
266
+ dagster_cloud_yaml_check,
267
+ prefix_message="[dagster_cloud.yaml] ",
268
+ success_message="Checked OK",
269
+ failure_message=(
270
+ "Invalid dagster_cloud.yaml, please see"
271
+ " https://docs.dagster.io/deployment/code-locations/dagster-cloud-yaml"
272
+ ),
273
+ )
274
+ )
275
+
276
+ if dagster_cloud_connect_check != checks.Check.skip:
277
+ if not organization:
278
+ raise ui.error(
279
+ "DAGSTER_CLOUD_ORGANIZATION or --organization required for connection check."
280
+ )
281
+ url = get_org_url(organization, dagster_env)
282
+ result = checks.check_connect_dagster_cloud(url)
283
+ verdicts.append(
284
+ checks.handle_result(
285
+ result,
286
+ dagster_cloud_connect_check,
287
+ prefix_message="[dagster.cloud connection] ",
288
+ success_message="Able to connect to dagster.cloud",
289
+ failure_message="Unable to connect to dagster.cloud",
290
+ )
291
+ )
292
+
293
+ verdict_counts = Counter(verdicts)
294
+ ui.print(f"Passed: {verdict_counts[checks.Verdict.passed]}")
295
+ if verdict_counts[checks.Verdict.warning]:
296
+ ui.print(f"Warnings: {verdict_counts[checks.Verdict.warning]}")
297
+ if verdict_counts[checks.Verdict.failed]:
298
+ ui.print(ui.red(f"Failed: {verdict_counts[checks.Verdict.failed]}"))
299
+ sys.exit(1)
300
+
301
+
302
+ STATEDIR_OPTION = typer.Option(..., envvar="DAGSTER_BUILD_STATEDIR")
303
+
304
+
305
+ @app.command(help="Render the processed dagster cloud configuration file to the console")
306
+ def template(
307
+ project_dir: str = typer.Option(".", help="Path to the project directory"),
308
+ dagster_cloud_yaml_path: str = typer.Option(
309
+ "dagster_cloud.yaml", help="Path to the dagster cloud configuration file"
310
+ ),
311
+ values_file: str | None = typer.Option(default=None, help="Path to a values file"),
312
+ value: list[str] = typer.Option(
313
+ [],
314
+ help="Key value pairs to override in the yaml file, value can be a str or a valid json representation",
315
+ ),
316
+ merge: bool = typer.Option(
317
+ True,
318
+ help="Render the yaml file with defaults merged in each location",
319
+ is_flag=True,
320
+ ),
321
+ ) -> None:
322
+ project_path = pathlib.Path(project_dir)
323
+ yaml_path = project_path / dagster_cloud_yaml_path
324
+ if not yaml_path.exists():
325
+ raise ui.error(f"No such file {yaml_path}")
326
+
327
+ context = _create_context_from_values(value, values_file)
328
+
329
+ template_loader = JinjaTemplateLoader()
330
+ rendered_config_yaml: str
331
+ try:
332
+ rendered_config_yaml = template_loader.render(filepath=str(yaml_path), context=context)
333
+ except TemplateSyntaxError as error:
334
+ raise ui.error(
335
+ f"jinja TemplateSyntaxError: {error.message} at line {error.lineno} in {error.filename}"
336
+ )
337
+ except Exception as error:
338
+ raise ui.error(f"Error rendering template: {error}")
339
+
340
+ if not merge:
341
+ ui.print(rendered_config_yaml)
342
+ return
343
+
344
+ try:
345
+ dagster_cloud_config = load_dagster_cloud_yaml(rendered_config_yaml)
346
+ except Exception as err:
347
+ raise ui.error(f"Error parsing the configuration: {err}")
348
+
349
+ config_merger = DagsterCloudConfigDefaultsMerger()
350
+ processed_config = config_merger.process(dagster_cloud_config)
351
+ config_as_dict = remove_none_recursively(processed_config.model_dump())
352
+ ui.print(yaml.dump(config_as_dict))
353
+
354
+
355
+ def _create_context_from_values(values_list: list[str], values_file: str | None) -> dict[str, Any]:
356
+ """Creates a collection of values by loading values from a file then
357
+ overlaying a value list to extend or override those values.
358
+ """
359
+ context: dict[str, Any] = {}
360
+
361
+ # load values from file
362
+ values_file_path = pathlib.Path(values_file) if values_file else None
363
+ if values_file_path:
364
+ try:
365
+ with open(values_file_path) as f:
366
+ if values_file_path.suffix == ".json":
367
+ context.update(json.load(f))
368
+ elif values_file_path.suffix in [".yaml", ".yml"]:
369
+ context.update(yaml.safe_load(f))
370
+ else:
371
+ raise ui.error(f"Unsupported values file extension {values_file_path.suffix}")
372
+ except Exception as err:
373
+ raise ui.error(f"Error parsing values file {values_file}: {err}")
374
+
375
+ # overlay command line value arguments
376
+ for value_entry in values_list:
377
+ key, value = value_entry.split("=", 1)
378
+ try:
379
+ # command line values may be expressed as json to allow for complex values
380
+ val = json.loads(value)
381
+ except json.JSONDecodeError:
382
+ val = value
383
+
384
+ # command line values may specify a path to set keys in
385
+ key_path = key.split(".") if "." in key else [key]
386
+ sub_context_ref = context
387
+ for step in key_path:
388
+ if step == key_path[-1]:
389
+ sub_context_ref[step] = val
390
+ elif step not in sub_context_ref:
391
+ sub_context_ref[step] = {}
392
+ sub_context_ref = sub_context_ref[step]
393
+ return context
394
+
395
+
396
+ @app.command(help="Initialize a build session")
397
+ @dagster_cloud_options(allow_empty=False, allow_empty_deployment=True, requires_url=False)
398
+ def init(
399
+ organization: str,
400
+ deployment: str | None,
401
+ dagster_env: str | None = DAGSTER_ENV_OPTION,
402
+ project_dir: str = typer.Option("."),
403
+ dagster_cloud_yaml_path: str = "dagster_cloud.yaml",
404
+ statedir: str = STATEDIR_OPTION,
405
+ clean_statedir: bool = typer.Option(True, help="Delete any existing files in statedir"),
406
+ location_name: list[str] = typer.Option([]),
407
+ git_url: str | None = None,
408
+ commit_hash: str | None = None,
409
+ status_url: str | None = None,
410
+ snapshot_base_condition: SnapshotBaseDeploymentCondition | None = None,
411
+ require_branch_deployment: bool = typer.Option(
412
+ None, help="Whether to require that a branch deployment be created"
413
+ ),
414
+ ):
415
+ init_impl(
416
+ organization,
417
+ deployment,
418
+ dagster_env,
419
+ project_dir,
420
+ dagster_cloud_yaml_path,
421
+ statedir,
422
+ clean_statedir,
423
+ location_name,
424
+ git_url,
425
+ commit_hash,
426
+ status_url,
427
+ snapshot_base_condition,
428
+ require_branch_deployment,
429
+ )
430
+
431
+
432
+ def init_impl(
433
+ organization: str,
434
+ deployment: str | None,
435
+ dagster_env: str | None,
436
+ project_dir: str,
437
+ dagster_cloud_yaml_path: str,
438
+ statedir: str,
439
+ clean_statedir: bool,
440
+ location_name: list[str],
441
+ git_url: str | None,
442
+ commit_hash: str | None,
443
+ status_url: str | None,
444
+ snapshot_base_condition: SnapshotBaseDeploymentCondition | None,
445
+ require_branch_deployment: bool,
446
+ ):
447
+ yaml_path = pathlib.Path(project_dir) / dagster_cloud_yaml_path
448
+ if not yaml_path.exists():
449
+ raise ui.error(
450
+ f"Dagster Cloud yaml file not found at specified path {yaml_path.resolve()}."
451
+ )
452
+ locations_def = load_dagster_cloud_yaml(yaml_path.read_text())
453
+ locations = locations_def.locations
454
+ if location_name:
455
+ selected_locations = set(location_name)
456
+ unknown = selected_locations - set(location.location_name for location in locations)
457
+ if unknown:
458
+ raise ui.error(f"Locations not found in {dagster_cloud_yaml_path}: {unknown}")
459
+ locations = [
460
+ location for location in locations if location.location_name in selected_locations
461
+ ]
462
+ # Check environment variable for URL first, fall back to constructing from org + env
463
+ env_url = os.getenv(URL_ENV_VAR_NAME)
464
+ if env_url:
465
+ url = env_url
466
+ else:
467
+ url = get_org_url(organization, dagster_env)
468
+ # Deploy to the branch deployment for the current context. If there is no branch deployment
469
+ # available (eg. if not in a PR) then we fallback to the --deployment flag.
470
+
471
+ try:
472
+ branch_deployment_name = create_or_update_deployment_from_context(
473
+ url,
474
+ project_dir,
475
+ mark_closed=False,
476
+ base_deployment_name=deployment,
477
+ snapshot_base_condition=snapshot_base_condition,
478
+ require_branch_deployment=require_branch_deployment,
479
+ )
480
+ if branch_deployment_name:
481
+ ui.print(
482
+ f"Deploying to branch deployment {branch_deployment_name}"
483
+ + (f", using base deployment {deployment}" if deployment else "")
484
+ )
485
+ deployment = branch_deployment_name
486
+ is_branch_deployment = True
487
+ else:
488
+ if not deployment:
489
+ raise ui.error("Cannot determine deployment name. Please specify --deployment.")
490
+
491
+ ui.print(f"Deploying to {deployment}.")
492
+ is_branch_deployment = False
493
+
494
+ except ValueError as err:
495
+ if deployment:
496
+ ui.print(f"Deploying to {deployment}. No branch deployment ({err}).")
497
+ is_branch_deployment = False
498
+ else:
499
+ raise ui.error(
500
+ f"Cannot determine deployment name in current context ({err}). Please specify"
501
+ " --deployment."
502
+ )
503
+
504
+ if require_branch_deployment and not is_branch_deployment:
505
+ raise ui.error(
506
+ "--branch-deployment flag was provided, but was unable to determine a branch deployment."
507
+ )
508
+
509
+ if clean_statedir and os.path.exists(statedir):
510
+ shutil.rmtree(statedir, ignore_errors=True)
511
+ state_store = state.FileStore(statedir=statedir)
512
+
513
+ ui.print(f"Initializing {statedir}")
514
+ for location in locations:
515
+ location_state = state.LocationState(
516
+ url=url,
517
+ deployment_name=deployment,
518
+ location_file=str(yaml_path.absolute()),
519
+ location_name=location.location_name,
520
+ is_branch_deployment=is_branch_deployment,
521
+ build=state.BuildMetadata(
522
+ git_url=git_url,
523
+ commit_hash=commit_hash,
524
+ build_config=location.build,
525
+ ),
526
+ build_output=None,
527
+ status_url=status_url,
528
+ project_dir=project_dir,
529
+ )
530
+ location_state.add_status_change(state.LocationStatus.pending, "initialized")
531
+ state_store.save(location_state)
532
+
533
+ ui.print(
534
+ f"Initialized {statedir} to build and deploying following locations for directory"
535
+ f" {project_dir}:"
536
+ )
537
+ for location in state_store.list_locations():
538
+ ui.print(f"- {location.location_name}")
539
+
540
+
541
+ class StatusOutputFormat(Enum):
542
+ json = "json"
543
+ markdown = "markdown"
544
+
545
+
546
+ @app.command(help="Show status of the current build session")
547
+ def status(
548
+ statedir: str = STATEDIR_OPTION,
549
+ output_format: StatusOutputFormat = typer.Option("json", help="Output format for build status"),
550
+ ):
551
+ state_store = state.FileStore(statedir=statedir)
552
+ location_states = state_store.list_locations()
553
+ if output_format == StatusOutputFormat.json:
554
+ for location in location_states:
555
+ ui.print(location.json())
556
+ elif output_format == StatusOutputFormat.markdown:
557
+ ui.print(report.markdown_report(location_states))
558
+
559
+
560
+ @app.command(help="Update the PR comment with the latest status for branch deployments in Github.")
561
+ def notify(
562
+ statedir: str = STATEDIR_OPTION,
563
+ project_dir: str = typer.Option("."),
564
+ ):
565
+ state_store = state.FileStore(statedir=statedir)
566
+ location_states = state_store.list_locations()
567
+
568
+ source = metrics.get_source()
569
+ if source == CliEventTags.source.github:
570
+ event = github_context.get_github_event(project_dir)
571
+ msg = f"Your pull request at commit `{event.github_sha}` is automatically being deployed to Dagster Cloud."
572
+ event.update_pr_comment(
573
+ msg + "\n\n" + report.markdown_report(location_states),
574
+ orig_author="github-actions[bot]",
575
+ orig_text="Dagster Cloud", # used to identify original comment
576
+ )
577
+ else:
578
+ raise ui.error("'dagster-cloud ci notify' is only available within Github actions.")
579
+
580
+
581
+ @app.command(help="List locations in the current build session")
582
+ def locations_list(
583
+ statedir: str = STATEDIR_OPTION,
584
+ ):
585
+ state_store = state.FileStore(statedir=statedir)
586
+ for location in state_store.list_locations():
587
+ if location.selected:
588
+ ui.print(f"{location.location_name}")
589
+ else:
590
+ ui.print(f"{location.location_name} DESELECTED")
591
+
592
+
593
+ @app.command(help="Mark the specified locations as excluded from the current build session")
594
+ def locations_deselect(
595
+ location_names: list[str],
596
+ statedir: str = STATEDIR_OPTION,
597
+ ):
598
+ state_store = state.FileStore(statedir=statedir)
599
+ state_store.deselect(location_names)
600
+ ui.print("Deselected locations: {locations_names}")
601
+
602
+
603
+ @app.command(help="Mark the specified locations as included in the current build session")
604
+ def locations_select(
605
+ location_names: list[str],
606
+ statedir: str = STATEDIR_OPTION,
607
+ ):
608
+ state_store = state.FileStore(statedir=statedir)
609
+ state_store.select(location_names)
610
+ ui.print("Deselected locations: {locations_names}")
611
+
612
+
613
+ def _get_selected_locations(
614
+ state_store: state.Store, location_name: list[str]
615
+ ) -> dict[str, state.LocationState]:
616
+ requested_locations = set(location_name)
617
+ selected = {
618
+ location.location_name: location
619
+ for location in state_store.list_selected_locations()
620
+ if not requested_locations or location.location_name in requested_locations
621
+ }
622
+ unknown_locations = requested_locations - set(selected)
623
+ if unknown_locations:
624
+ raise ui.error(f"Unknown or deselected location names requested: {unknown_locations}")
625
+ return selected
626
+
627
+
628
+ class BuildStrategy(Enum):
629
+ pex = "python-executable"
630
+ docker = "docker"
631
+
632
+
633
+ @app.command(help="Build selected or requested locations")
634
+ def build(
635
+ statedir: str = STATEDIR_OPTION,
636
+ location_name: list[str] = typer.Option([]),
637
+ build_directory: str | None = typer.Option(
638
+ None,
639
+ help=(
640
+ "Directory root for building this code location. Read from dagster_cloud.yaml by"
641
+ " default."
642
+ ),
643
+ ),
644
+ build_strategy: BuildStrategy = typer.Option(
645
+ "docker",
646
+ help=(
647
+ "Build strategy used to build code locations. 'docker' builds a docker image."
648
+ " 'python-executable' builds a set of pex files."
649
+ ),
650
+ ),
651
+ docker_image_tag: str | None = typer.Option(
652
+ None, help="Tag for built docker image. Auto-generated by default."
653
+ ),
654
+ docker_base_image: str | None = typer.Option(
655
+ None,
656
+ help="Base image used to build the docker image for --build-strategy=docker.",
657
+ ),
658
+ docker_env: list[str] = typer.Option([], help="Env vars for docker builds."),
659
+ dockerfile_path: str | None = typer.Option(
660
+ None,
661
+ help=(
662
+ "Path to a Dockerfile to use for the docker build. If not provided, a default templated Dockerfile is used."
663
+ ),
664
+ ),
665
+ python_version: str = typer.Option(
666
+ DEFAULT_PYTHON_VERSION,
667
+ help=(
668
+ "Python version used to build the python-executable; or to determine the default base"
669
+ " image for docker."
670
+ ),
671
+ ),
672
+ pex_build_method: deps.BuildMethod = typer.Option("local"),
673
+ pex_deps_cache_from: str | None = None,
674
+ pex_deps_cache_to: str | None = None,
675
+ pex_base_image_tag: str | None = typer.Option(
676
+ None,
677
+ help="Base image used to run python executable for --build-strategy=python-executable.",
678
+ ),
679
+ use_editable_dagster: bool = typer.Option(
680
+ False,
681
+ help="Include the editable dagster package in the Docker context for the build.",
682
+ ),
683
+ ) -> None:
684
+ build_impl(
685
+ statedir,
686
+ location_name,
687
+ build_directory,
688
+ build_strategy,
689
+ docker_image_tag,
690
+ docker_base_image,
691
+ docker_env,
692
+ dockerfile_path,
693
+ python_version,
694
+ pex_build_method,
695
+ pex_deps_cache_from,
696
+ pex_deps_cache_to,
697
+ pex_base_image_tag,
698
+ use_editable_dagster,
699
+ )
700
+
701
+
702
+ def build_impl(
703
+ statedir: str,
704
+ location_name: list[str],
705
+ build_directory: str | None,
706
+ build_strategy: BuildStrategy,
707
+ docker_image_tag: str | None,
708
+ docker_base_image: str | None,
709
+ docker_env: list[str],
710
+ dockerfile_path: str | None,
711
+ python_version: str,
712
+ pex_build_method: deps.BuildMethod,
713
+ pex_deps_cache_from: str | None,
714
+ pex_deps_cache_to: str | None,
715
+ pex_base_image_tag: str | None,
716
+ use_editable_dagster: bool,
717
+ ):
718
+ if python_version:
719
+ # ensure version is parseable
720
+ pex_builder.util.parse_python_version(python_version)
721
+
722
+ if build_strategy == BuildStrategy.pex:
723
+ if docker_base_image or docker_image_tag:
724
+ raise ui.error(
725
+ "--base-image or --image-tag not supported for --build-strategy=python-executable."
726
+ )
727
+
728
+ if docker_base_image and dockerfile_path:
729
+ raise ui.error(
730
+ "--base-image and --dockerfile-path cannot both be provided. Please provide only one."
731
+ )
732
+
733
+ state_store = state.FileStore(statedir=statedir)
734
+ locations = _get_selected_locations(state_store, location_name)
735
+ ui.print("Going to build the following locations:")
736
+ for name in locations:
737
+ ui.print(f"- {name}")
738
+
739
+ for name, location_state in locations.items():
740
+ project_dir = location_state.project_dir
741
+ try:
742
+ configured_build_directory = (
743
+ location_state.build.build_config.directory
744
+ if (
745
+ location_state.build.build_config
746
+ and location_state.build.build_config.directory
747
+ )
748
+ else None
749
+ )
750
+ if build_directory and configured_build_directory:
751
+ ui.warn(
752
+ f"Overriding configured build:directory:{configured_build_directory!r} with"
753
+ f" cmdline provided --build-directory={build_directory!r}"
754
+ )
755
+ location_build_dir = build_directory
756
+ elif (not build_directory) and configured_build_directory:
757
+ location_build_dir = configured_build_directory
758
+ elif build_directory and (not configured_build_directory):
759
+ location_build_dir = build_directory
760
+ else:
761
+ location_build_dir = "."
762
+
763
+ if project_dir and not os.path.isabs(location_build_dir):
764
+ location_build_dir = str(pathlib.Path(project_dir) / location_build_dir)
765
+
766
+ url = location_state.url
767
+ api_token = get_user_token() or ""
768
+
769
+ if build_strategy == BuildStrategy.docker:
770
+ location_state.build_output = _build_docker(
771
+ url=url,
772
+ api_token=api_token,
773
+ name=name,
774
+ location_build_dir=location_build_dir,
775
+ docker_base_image=docker_base_image,
776
+ python_version=python_version,
777
+ docker_env=docker_env,
778
+ location_state=location_state,
779
+ dockerfile_path=dockerfile_path,
780
+ use_editable_dagster=use_editable_dagster,
781
+ )
782
+ state_store.save(location_state)
783
+ elif build_strategy == BuildStrategy.pex:
784
+ location_state.build_output = _build_pex(
785
+ url=url,
786
+ api_token=api_token,
787
+ name=name,
788
+ location_build_dir=location_build_dir,
789
+ python_version=python_version,
790
+ pex_build_method=pex_build_method,
791
+ pex_deps_cache_from=pex_deps_cache_from,
792
+ pex_deps_cache_to=pex_deps_cache_to,
793
+ pex_base_image_tag=pex_base_image_tag,
794
+ location_state=location_state,
795
+ )
796
+ state_store.save(location_state)
797
+ ui.print(
798
+ "Built and uploaded python executable"
799
+ f" {location_state.build_output.pex_tag} for location {name}"
800
+ )
801
+ except:
802
+ location_state.add_status_change(state.LocationStatus.failed, "build failed")
803
+ state_store.save(location_state)
804
+ raise
805
+ else:
806
+ location_state.add_status_change(state.LocationStatus.pending, "build successful")
807
+ state_store.save(location_state)
808
+
809
+
810
+ @metrics.instrument(
811
+ CliEventType.BUILD,
812
+ tags=[
813
+ CliEventTags.subcommand.dagster_cloud_ci,
814
+ CliEventTags.server_strategy.docker,
815
+ ],
816
+ )
817
+ # url and api_token are used by the instrument decorator
818
+ def _build_docker(
819
+ url: str,
820
+ api_token: str,
821
+ name: str,
822
+ location_build_dir: str,
823
+ python_version: str,
824
+ docker_base_image: str | None,
825
+ docker_env: list[str],
826
+ location_state: state.LocationState,
827
+ use_editable_dagster: bool,
828
+ dockerfile_path: str | None = None,
829
+ ) -> state.DockerBuildOutput:
830
+ name = location_state.location_name
831
+ docker_utils.verify_docker()
832
+ registry_info = utils.get_registry_info(url)
833
+
834
+ docker_image_tag = docker_utils.default_image_tag(
835
+ location_state.deployment_name, name, location_state.build.commit_hash
836
+ )
837
+
838
+ if not dockerfile_path and not docker_base_image:
839
+ docker_base_image = f"python:{python_version}-slim"
840
+
841
+ ui.print(
842
+ f"Building docker image for location {name}"
843
+ + (f" using base image {docker_base_image}" if docker_base_image else "")
844
+ )
845
+
846
+ retval = docker_utils.build_image(
847
+ location_build_dir,
848
+ docker_image_tag,
849
+ registry_info,
850
+ env_vars=docker_env,
851
+ base_image=docker_base_image,
852
+ dockerfile_path=dockerfile_path,
853
+ use_editable_dagster=use_editable_dagster,
854
+ )
855
+ if retval != 0:
856
+ raise ui.error(f"Failed to build docker image for location {name}")
857
+
858
+ retval = docker_utils.upload_image(docker_image_tag, registry_info)
859
+ if retval != 0:
860
+ raise ui.error(f"Failed to upload docker image for location {name}")
861
+
862
+ image = f"{registry_info['registry_url']}:{docker_image_tag}"
863
+ ui.print(f"Built and uploaded image {image} for location {name}")
864
+
865
+ return state.DockerBuildOutput(image=image)
866
+
867
+
868
+ @metrics.instrument(
869
+ CliEventType.BUILD,
870
+ tags=[CliEventTags.subcommand.dagster_cloud_ci, CliEventTags.server_strategy.pex],
871
+ )
872
+ # url and api_token are used by the instrument decorator
873
+ def _build_pex(
874
+ url: str,
875
+ api_token: str,
876
+ name: str,
877
+ location_build_dir: str,
878
+ python_version: str,
879
+ pex_build_method: deps.BuildMethod,
880
+ pex_deps_cache_from: str | None,
881
+ pex_deps_cache_to: str | None,
882
+ pex_base_image_tag: str | None,
883
+ location_state: state.LocationState,
884
+ ) -> state.PexBuildOutput:
885
+ pex_location = parse_workspace.Location(
886
+ name,
887
+ directory=location_build_dir,
888
+ build_folder=location_build_dir,
889
+ location_file=location_state.location_file,
890
+ )
891
+
892
+ location_kwargs = pex_utils.build_upload_pex(
893
+ url=url,
894
+ api_token=api_token,
895
+ location=pex_location,
896
+ build_method=pex_build_method,
897
+ kwargs={
898
+ "python_version": python_version,
899
+ "base_image_tag": pex_base_image_tag,
900
+ "deps_cache_from": pex_deps_cache_from,
901
+ "deps_cache_to": pex_deps_cache_to,
902
+ },
903
+ )
904
+ return state.PexBuildOutput(
905
+ python_version=python_version,
906
+ image=location_kwargs.get("image"),
907
+ pex_tag=location_kwargs["pex_tag"],
908
+ )
909
+
910
+
911
+ @app.command(help="Update the current build session for an externally built docker image.")
912
+ def set_build_output(
913
+ statedir: str = STATEDIR_OPTION,
914
+ location_name: list[str] = typer.Option([]),
915
+ image_tag: str = typer.Option(
916
+ ...,
917
+ help=(
918
+ "Tag for the built docker image. Note the registry must be specified in"
919
+ " dagster_cloud.yaml."
920
+ ),
921
+ ),
922
+ ) -> None:
923
+ set_build_output_impl(
924
+ statedir,
925
+ location_name,
926
+ image_tag,
927
+ )
928
+
929
+
930
+ def set_build_output_impl(
931
+ statedir: str,
932
+ location_name: list[str],
933
+ image_tag: str,
934
+ ) -> None:
935
+ state_store = state.FileStore(statedir=statedir)
936
+ locations = _get_selected_locations(state_store, location_name)
937
+ ui.print("Going to update the following locations:")
938
+ for name in locations:
939
+ ui.print(f"- {name}")
940
+
941
+ # validation pass - computes the full image name for all locations
942
+ images = {}
943
+ for name, location_state in locations.items():
944
+ configured_defs = load_dagster_cloud_yaml(
945
+ open(location_state.location_file, encoding="utf-8").read()
946
+ )
947
+ location_defs = [loc for loc in configured_defs.locations if loc.location_name == name]
948
+ if not location_defs:
949
+ raise ui.error(f"Location {name} not found in {location_state.location_file}")
950
+ location_def = location_defs[0]
951
+ registry = location_def.build.registry if location_def.build else None
952
+ if not registry:
953
+ raise ui.error(
954
+ f"No build:registry: defined for location {name} in {location_state.location_file}"
955
+ )
956
+
957
+ images[name] = f"{registry}:{image_tag}"
958
+
959
+ # save pass - save full image name computed in the previous pass for all locations
960
+ for name, location_state in locations.items():
961
+ # Update and save build state
962
+ location_state.build_output = state.DockerBuildOutput(image=images[name])
963
+ state_store.save(location_state)
964
+ ui.print(f"Recorded image {images[name]} for location {name}")
965
+ ui.print("Use 'ci deploy' to update dagster-cloud.")
966
+
967
+
968
+ @app.command(help="Deploy built code locations to dagster cloud.")
969
+ def deploy(
970
+ statedir: str = STATEDIR_OPTION,
971
+ location_name: list[str] = typer.Option([]),
972
+ location_load_timeout: int = LOCATION_LOAD_TIMEOUT_OPTION,
973
+ agent_heartbeat_timeout: int = get_agent_heartbeat_timeout_option(default_timeout=None),
974
+ ):
975
+ deploy_impl(statedir, location_name, location_load_timeout, agent_heartbeat_timeout)
976
+
977
+
978
+ def deploy_impl(
979
+ statedir: str,
980
+ location_name: list[str],
981
+ location_load_timeout: int,
982
+ agent_heartbeat_timeout: int | None,
983
+ ):
984
+ state_store = state.FileStore(statedir=statedir)
985
+ locations = _get_selected_locations(state_store, location_name)
986
+ ui.print("Going to deploy the following locations:")
987
+
988
+ built_locations: list[state.LocationState] = []
989
+ unbuilt_location_names: list[str] = []
990
+ for name, location_state in locations.items():
991
+ if location_state.build_output:
992
+ status = "Ready to deploy"
993
+ built_locations.append(location_state)
994
+ else:
995
+ status = "Not ready to deploy"
996
+ unbuilt_location_names.append(name)
997
+ ui.print(f"- {name} [{status}]")
998
+
999
+ if unbuilt_location_names:
1000
+ raise ui.error(
1001
+ f"Cannot deploy because the following location{'s have' if len(unbuilt_location_names) > 1 else ' has'} "
1002
+ f"not been built: {', '.join(unbuilt_location_names)}. "
1003
+ "Use 'ci build' (in Dagster+ Serverless) or `ci set-build-output` (in Dagster+ Hybrid) to build"
1004
+ " locations."
1005
+ )
1006
+
1007
+ if not built_locations:
1008
+ ui.print("No locations to deploy")
1009
+ return
1010
+
1011
+ try:
1012
+ _deploy(
1013
+ url=built_locations[0].url,
1014
+ api_token=check.not_none(get_user_token()),
1015
+ built_locations=built_locations,
1016
+ location_load_timeout=location_load_timeout,
1017
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
1018
+ )
1019
+ except:
1020
+ # unfortunately we do not know if only a subset of locations failed to deploy
1021
+ for location_state in built_locations:
1022
+ location_state.add_status_change(state.LocationStatus.failed, "deploy failed")
1023
+ state_store.save(location_state)
1024
+ raise
1025
+ else:
1026
+ for location_state in built_locations:
1027
+ location_state.add_status_change(state.LocationStatus.success, "deploy successful")
1028
+ state_store.save(location_state)
1029
+
1030
+ deployment_url = built_locations[0].url + "/" + built_locations[0].deployment_name
1031
+ ui.print(f"View the status of your locations at {deployment_url}/locations.")
1032
+
1033
+
1034
+ @metrics.instrument(CliEventType.DEPLOY, tags=[CliEventTags.subcommand.dagster_cloud_ci])
1035
+ # url and api_token are used by the instrument decorator
1036
+ def _deploy(
1037
+ *,
1038
+ url: str,
1039
+ api_token: str,
1040
+ built_locations: list[state.LocationState],
1041
+ location_load_timeout: int,
1042
+ agent_heartbeat_timeout: int | None,
1043
+ ):
1044
+ locations_document = []
1045
+ for location_state in built_locations:
1046
+ build_output = location_state.build_output
1047
+ if not build_output: # not necessary but keep type checker happy
1048
+ continue
1049
+
1050
+ location_args = {
1051
+ "image": build_output.image,
1052
+ "location_file": location_state.location_file,
1053
+ "git_url": location_state.build.git_url,
1054
+ "commit_hash": location_state.build.commit_hash,
1055
+ **(
1056
+ {"defs_state_info": location_state.defs_state_info.model_dump()}
1057
+ if location_state.defs_state_info
1058
+ else {}
1059
+ ),
1060
+ }
1061
+ if build_output.strategy == "python-executable":
1062
+ metrics.instrument_add_tags([CliEventTags.server_strategy.pex])
1063
+ location_args["pex_tag"] = build_output.pex_tag
1064
+ location_args["python_version"] = build_output.python_version
1065
+ else:
1066
+ metrics.instrument_add_tags([CliEventTags.server_strategy.docker])
1067
+
1068
+ locations_document.append(
1069
+ get_location_document(location_state.location_name, location_args)
1070
+ )
1071
+
1072
+ deployment_url = built_locations[0].url + "/" + built_locations[0].deployment_name
1073
+ with utils.client_from_env(
1074
+ built_locations[0].url, deployment=built_locations[0].deployment_name
1075
+ ) as client:
1076
+ location_names = [location_state.location_name for location_state in built_locations]
1077
+ gql.deploy_code_locations(client, {"locations": locations_document})
1078
+ ui.print(
1079
+ f"Updated code location{'s' if len(location_names) > 1 else ''} {', '.join(location_names)} in dagster-cloud."
1080
+ )
1081
+
1082
+ if not agent_heartbeat_timeout:
1083
+ agent_type = gql.fetch_agent_type(client)
1084
+ # raise the agent heartbeat timeout for serverless deploys to ensure that the
1085
+ # deploy doesn't fail if the agent is still being spun up. A deploy is serverless if
1086
+ # that's the default agent type for the deployment and the locations aren't being
1087
+ # deployed to some other agent queue (since serverless agents only serve the default
1088
+ # agent queue)
1089
+ if agent_type != DagsterPlusDeploymentAgentType.SERVERLESS or any(
1090
+ location_config.get("agent_queue") for location_config in locations_document
1091
+ ):
1092
+ agent_heartbeat_timeout = DEFAULT_HYBRID_AGENT_HEARTBEAT_TIMEOUT
1093
+ else:
1094
+ agent_heartbeat_timeout = DEFAULT_SERVERLESS_AGENT_HEARTBEAT_TIMEOUT
1095
+
1096
+ wait_for_load(
1097
+ client,
1098
+ location_names,
1099
+ location_load_timeout=location_load_timeout,
1100
+ agent_heartbeat_timeout=agent_heartbeat_timeout,
1101
+ url=deployment_url,
1102
+ )
1103
+
1104
+
1105
+ dagster_dbt_app = typer.Typer(
1106
+ hidden=True,
1107
+ help="Dagster Cloud commands for managing the `dagster-dbt` integration.",
1108
+ add_completion=False,
1109
+ )
1110
+ app.add_typer(dagster_dbt_app, name="dagster-dbt", no_args_is_help=True)
1111
+
1112
+ project_app = typer.Typer(
1113
+ name="project",
1114
+ no_args_is_help=True,
1115
+ help="Commands for using a dbt project in Dagster.",
1116
+ add_completion=False,
1117
+ )
1118
+ dagster_dbt_app.add_typer(project_app, name="project", no_args_is_help=True)
1119
+
1120
+
1121
+ @project_app.command(
1122
+ name="manage-state",
1123
+ help="""
1124
+ This CLI command will handle uploading and downloading dbt state, in the form of manifest.json,
1125
+ if `state_path` is specified on `DbtProject`.
1126
+ """,
1127
+ )
1128
+ def manage_state_command(
1129
+ statedir: str = STATEDIR_OPTION,
1130
+ file: Annotated[
1131
+ pathlib.Path | None,
1132
+ typer.Option(
1133
+ help="The file containing DbtProject definitions to prepare.",
1134
+ ),
1135
+ ] = None,
1136
+ components: Annotated[
1137
+ pathlib.Path | None,
1138
+ typer.Option(
1139
+ help="The path to a dg project directory containing DbtProjectComponents.",
1140
+ ),
1141
+ ] = None,
1142
+ source_deployment: Annotated[
1143
+ str,
1144
+ typer.Option(
1145
+ help="Which deployment should upload its manifest.json.",
1146
+ ),
1147
+ ] = "prod",
1148
+ key_prefix: Annotated[
1149
+ str,
1150
+ typer.Option(
1151
+ help="A key prefix for the key the manifest.json is saved with.",
1152
+ ),
1153
+ ] = "",
1154
+ ):
1155
+ try:
1156
+ from dagster_dbt import DbtProject
1157
+ except:
1158
+ ui.print(
1159
+ "Unable to import dagster_dbt. To use `manage-state`, dagster_dbt must be installed."
1160
+ )
1161
+ return
1162
+ try:
1163
+ from dagster._core.code_pointer import load_python_file
1164
+ from dagster._core.definitions.module_loaders.utils import find_objects_in_module_of_types
1165
+ except:
1166
+ ui.print("Unable to import dagster. To use `manage-state`, dagster must be installed.")
1167
+ return
1168
+
1169
+ state_store = state.FileStore(statedir=statedir)
1170
+ locations = state_store.list_locations()
1171
+ if not locations:
1172
+ raise ui.error("Unable to determine deployment state.")
1173
+
1174
+ location = locations[0]
1175
+ deployment_name = location.deployment_name
1176
+ is_branch = location.is_branch_deployment
1177
+ if file:
1178
+ contents = load_python_file(file, None)
1179
+ projects = find_objects_in_module_of_types(contents, DbtProject)
1180
+ elif components:
1181
+ from dagster_dbt.components.dbt_project.component import get_projects_from_dbt_component
1182
+
1183
+ projects = get_projects_from_dbt_component(components)
1184
+ else:
1185
+ raise click.UsageError("Must specify --file or --components")
1186
+
1187
+ for project in projects:
1188
+ project = cast("DbtProject", project)
1189
+ if project.state_path:
1190
+ download_path = project.state_path.joinpath("manifest.json")
1191
+ key = f"{key_prefix}{os.fspath(download_path)}"
1192
+ if is_branch:
1193
+ ui.print(f"Downloading {source_deployment} manifest for branch deployment.")
1194
+ os.makedirs(project.state_path, exist_ok=True)
1195
+ download_organization_artifact(key, download_path)
1196
+ ui.print("Download complete.")
1197
+
1198
+ elif deployment_name == source_deployment:
1199
+ ui.print(f"Uploading {source_deployment} manifest.")
1200
+ upload_organization_artifact(key, project.manifest_path)
1201
+ ui.print("Upload complete")
1202
+
1203
+ else:
1204
+ ui.warn(
1205
+ f"Deployment named {deployment_name} does not match source deployment {source_deployment}, taking no action. "
1206
+ f"If this is the desired dbt state artifacts to upload, set the cli flags `--source-deployment {deployment_name}`."
1207
+ )