dagflow-cli-plugin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Snowflake CLI plugin support for Dagflow."""
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import tomllib
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Iterator
9
+
10
+ import click
11
+ import typer
12
+ from snowflake.cli.api.commands.decorators import with_output
13
+ from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
14
+ from snowflake.cli.api.output.types import CommandResult, MultipleResults, ObjectResult, SingleQueryResult
15
+
16
+ from dagflow_cli.launch import build_launch_execution_params, launch_job, load_run_config, parse_tags
17
+ from dagflow_cli.manager import DagflowCliManager, Entrypoint
18
+ from dagflow_cli.pex import (
19
+ DEFAULT_PYTHON_VERSION,
20
+ PexBuildConfig,
21
+ build_pex_artifacts,
22
+ default_output_dir,
23
+ default_version,
24
+ )
25
+ from dagflow_cli.workspace import (
26
+ reload_repository_location,
27
+ reload_workspace,
28
+ wait_for_code_location_loaded,
29
+ )
30
+
31
+
32
+ app = SnowTyperFactory(name="dg", help="Manage Dagflow deployments")
33
+ code_location_app = SnowTyperFactory(name="code-location", help="Manage Dagflow code locations")
34
+ job_app = SnowTyperFactory(name="job", help="Manage Dagflow jobs")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DgProjectDefaults:
39
+ path: Path
40
+ location_name: str
41
+ module_name: str
42
+
43
+
44
+ def _entrypoint(module_name: str | None, package_name: str | None, python_file: Path | None) -> Entrypoint:
45
+ selected = [value is not None for value in (module_name, package_name, python_file)]
46
+ if sum(selected) != 1:
47
+ raise typer.BadParameter("pass exactly one of --module-name, --package-name, or --python-file")
48
+ if module_name is not None:
49
+ return Entrypoint("module", module_name)
50
+ if package_name is not None:
51
+ return Entrypoint("package", package_name)
52
+ assert python_file is not None
53
+ return Entrypoint("python_file", python_file.as_posix())
54
+
55
+
56
+ def _looks_like_dg_project(path: Path) -> bool:
57
+ project_path = path.expanduser().resolve()
58
+ if (project_path / "dg.toml").exists():
59
+ return True
60
+ pyproject = project_path / "pyproject.toml"
61
+ if not pyproject.exists():
62
+ return False
63
+ try:
64
+ config = tomllib.loads(pyproject.read_text())
65
+ except tomllib.TOMLDecodeError:
66
+ return False
67
+ return "dg" in config.get("tool", {})
68
+
69
+
70
+ def _dg_project_defaults(path: Path) -> DgProjectDefaults | None:
71
+ project_path = path.expanduser().resolve()
72
+ if not _looks_like_dg_project(project_path):
73
+ return None
74
+
75
+ try:
76
+ from dagster_dg_core.context import DgContext
77
+ except ImportError as error:
78
+ raise click.ClickException(
79
+ "This looks like a Dagster dg project, but dagster-dg-core is not installed. "
80
+ 'Install the Dagflow CLI plugin: pip install "dagflow-cli-plugin".'
81
+ ) from error
82
+
83
+ try:
84
+ context = DgContext.for_project_environment(
85
+ project_path,
86
+ {"suppress_warnings": ["project_and_activated_venv_mismatch"]},
87
+ )
88
+ except SystemExit as error:
89
+ raise click.ClickException(f"Could not load Dagster dg project at {project_path}") from error
90
+ except Exception as error:
91
+ raise click.ClickException(f"Could not load Dagster dg project at {project_path}: {error}") from error
92
+
93
+ build_path = context.root_path
94
+ build_config = context.build_config
95
+ if build_config and build_config.get("directory"):
96
+ build_path = Path(build_config["directory"])
97
+
98
+ return DgProjectDefaults(
99
+ path=build_path,
100
+ location_name=context.code_location_name,
101
+ module_name=context.code_location_target_module_name,
102
+ )
103
+
104
+
105
+ def _resolve_code_location_inputs(
106
+ path: Path,
107
+ location_name: str | None,
108
+ module_name: str | None,
109
+ package_name: str | None,
110
+ python_file: Path | None,
111
+ ) -> tuple[Path, str, Entrypoint]:
112
+ defaults = _dg_project_defaults(path)
113
+ resolved_path = defaults.path if defaults else path
114
+ resolved_location_name = location_name or (defaults.location_name if defaults else None)
115
+ resolved_module_name = module_name or (
116
+ defaults.module_name if defaults and package_name is None and python_file is None else None
117
+ )
118
+
119
+ if resolved_location_name is None:
120
+ raise typer.BadParameter(
121
+ "missing code location name; pass NAME or run from a Dagster dg project with code_location_name"
122
+ )
123
+
124
+ return (
125
+ resolved_path,
126
+ resolved_location_name,
127
+ _entrypoint(resolved_module_name, package_name, python_file),
128
+ )
129
+
130
+
131
+ def _build_pex_artifacts_for_cli(config: PexBuildConfig):
132
+ try:
133
+ return build_pex_artifacts(config)
134
+ except ValueError as error:
135
+ raise click.ClickException(str(error)) from None
136
+
137
+
138
+ @contextmanager
139
+ def _build_artifacts(
140
+ path: Path,
141
+ python_version: str,
142
+ ) -> Iterator[tuple[Path, Path]]:
143
+ with tempfile.TemporaryDirectory(prefix="dagflow-pex-") as temp_dir:
144
+ artifacts = _build_pex_artifacts_for_cli(
145
+ PexBuildConfig(
146
+ project_path=path,
147
+ output_dir=Path(temp_dir),
148
+ python_version=python_version,
149
+ )
150
+ )
151
+ yield artifacts.source_pex, artifacts.deps_pex
152
+
153
+
154
+ def _require_existing_pex(path: Path, option_name: str) -> Path:
155
+ resolved = path.expanduser().resolve()
156
+ if not resolved.exists():
157
+ raise click.ClickException(f"{option_name} does not exist: {resolved}")
158
+ if not resolved.is_file():
159
+ raise click.ClickException(f"{option_name} is not a file: {resolved}")
160
+ return resolved
161
+
162
+
163
+ @code_location_app.command("package")
164
+ def package(
165
+ path: Path = typer.Argument(..., help="Path to the Dagster code location project"),
166
+ location_name: str = typer.Option(..., "--location-name", help="Dagflow code location name"),
167
+ version: str | None = typer.Option(None, "--version", help="PEX artifact version"),
168
+ module_name: str | None = typer.Option(None, "--module-name", help="Dagster definitions module"),
169
+ package_name: str | None = typer.Option(None, "--package-name", help="Dagster definitions package"),
170
+ python_file: Path | None = typer.Option(None, "--python-file", help="Dagster definitions Python file"),
171
+ python_version: str = typer.Option(DEFAULT_PYTHON_VERSION, "--python-version", help="Target runtime Python version"),
172
+ output_dir: Path | None = typer.Option(None, "--output-dir", help="Directory for source.pex and deps.pex"),
173
+ ) -> None:
174
+ """Build source.pex and deps.pex for a Dagflow code location."""
175
+ _entrypoint(module_name, package_name, python_file)
176
+ resolved_version = version or default_version()
177
+ artifacts = _build_pex_artifacts_for_cli(
178
+ PexBuildConfig(
179
+ project_path=path,
180
+ output_dir=output_dir or default_output_dir(location_name, resolved_version),
181
+ python_version=python_version,
182
+ )
183
+ )
184
+ typer.echo(f"source_pex={artifacts.source_pex}")
185
+ typer.echo(f"deps_pex={artifacts.deps_pex}")
186
+
187
+
188
+ def _upload_and_deploy_code_location(
189
+ app_name: str | None,
190
+ location_name: str,
191
+ version: str,
192
+ entrypoint: Entrypoint,
193
+ python_version: str,
194
+ source_pex: Path,
195
+ deps_pex: Path,
196
+ blocking: bool,
197
+ ) -> CommandResult:
198
+ manager = DagflowCliManager()
199
+ manager.upload_artifacts(app_name, location_name, version, source_pex, deps_pex)
200
+ result = manager.deploy_code_location_api(
201
+ app_name,
202
+ location_name,
203
+ version,
204
+ entrypoint,
205
+ python_version,
206
+ blocking,
207
+ )
208
+ return ObjectResult(result)
209
+
210
+
211
+ def _deploy_code_location(
212
+ path: Path,
213
+ app_name: str | None,
214
+ location_name: str | None,
215
+ version: str | None,
216
+ module_name: str | None,
217
+ package_name: str | None,
218
+ python_file: Path | None,
219
+ python_version: str,
220
+ blocking: bool,
221
+ ) -> CommandResult:
222
+ resolved_path, resolved_location_name, entrypoint = _resolve_code_location_inputs(
223
+ path, location_name, module_name, package_name, python_file
224
+ )
225
+ resolved_version = version or default_version()
226
+ with _build_artifacts(
227
+ path=resolved_path,
228
+ python_version=python_version,
229
+ ) as (built_source_pex, built_deps_pex):
230
+ return _upload_and_deploy_code_location(
231
+ app_name=app_name,
232
+ location_name=resolved_location_name,
233
+ version=resolved_version,
234
+ entrypoint=entrypoint,
235
+ python_version=python_version,
236
+ source_pex=built_source_pex,
237
+ deps_pex=built_deps_pex,
238
+ blocking=blocking,
239
+ )
240
+
241
+
242
+ @app.command("deploy", requires_connection=True, requires_global_options=True)
243
+ @with_output
244
+ def deploy(
245
+ name: str | None = typer.Argument(None, help="Dagflow code location name"),
246
+ path: Path = typer.Option(Path("."), "--path", help="Path to the Dagster code location project"),
247
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
248
+ version: str | None = typer.Option(None, "--version", help="PEX artifact version"),
249
+ module_name: str | None = typer.Option(None, "--module-name", help="Dagster definitions module"),
250
+ package_name: str | None = typer.Option(None, "--package-name", help="Dagster definitions package"),
251
+ python_file: Path | None = typer.Option(None, "--python-file", help="Dagster definitions Python file"),
252
+ python_version: str = typer.Option(DEFAULT_PYTHON_VERSION, "--python-version", help="Target runtime Python version"),
253
+ blocking: bool = typer.Option(True, "--blocking/--no-blocking", help="Wait for code location readiness"),
254
+ **options: Any,
255
+ ) -> CommandResult:
256
+ """Package and deploy a Dagflow code location."""
257
+ return _deploy_code_location(
258
+ path=path,
259
+ app_name=app_name,
260
+ location_name=name,
261
+ version=version,
262
+ module_name=module_name,
263
+ package_name=package_name,
264
+ python_file=python_file,
265
+ python_version=python_version,
266
+ blocking=blocking,
267
+ )
268
+
269
+
270
+ @code_location_app.command("add", requires_connection=True, requires_global_options=True)
271
+ @with_output
272
+ def add(
273
+ name: str = typer.Argument(..., help="Dagflow code location name"),
274
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
275
+ version: str | None = typer.Option(None, "--version", help="PEX artifact version"),
276
+ module_name: str | None = typer.Option(None, "--module-name", help="Dagster definitions module"),
277
+ package_name: str | None = typer.Option(None, "--package-name", help="Dagster definitions package"),
278
+ python_file: Path | None = typer.Option(None, "--python-file", help="Dagster definitions Python file"),
279
+ python_version: str = typer.Option(DEFAULT_PYTHON_VERSION, "--python-version", help="Target runtime Python version"),
280
+ source_pex: Path = typer.Option(..., "--source-pex", help="Existing source.pex to upload"),
281
+ deps_pex: Path = typer.Option(..., "--deps-pex", help="Existing deps.pex to upload"),
282
+ blocking: bool = typer.Option(True, "--blocking/--no-blocking", help="Wait for code location readiness"),
283
+ **options: Any,
284
+ ) -> CommandResult:
285
+ """Add or update a Dagflow code location from existing PEX artifacts."""
286
+ resolved_version = version or default_version()
287
+ entrypoint = _entrypoint(module_name, package_name, python_file)
288
+ return _upload_and_deploy_code_location(
289
+ app_name=app_name,
290
+ location_name=name,
291
+ version=resolved_version,
292
+ entrypoint=entrypoint,
293
+ python_version=python_version,
294
+ source_pex=_require_existing_pex(source_pex, "--source-pex"),
295
+ deps_pex=_require_existing_pex(deps_pex, "--deps-pex"),
296
+ blocking=blocking,
297
+ )
298
+
299
+
300
+ def _delete_code_location(name: str, app_name: str | None, yes: bool) -> CommandResult:
301
+ if not yes:
302
+ typer.confirm(f"Delete code location {name}?", abort=True)
303
+ manager = DagflowCliManager()
304
+ return ObjectResult(manager.remove_code_location_api(app_name, name))
305
+
306
+
307
+ def _reload_code_location(name: str, app_name: str | None, blocking: bool) -> CommandResult:
308
+ manager = DagflowCliManager()
309
+ endpoint_url = manager.dagster_endpoint_url(app_name)
310
+ token = manager.session_token()
311
+ result = reload_repository_location(
312
+ endpoint_url=endpoint_url,
313
+ token=token,
314
+ location_name=name,
315
+ )
316
+ if blocking:
317
+ wait_for_code_location_loaded(
318
+ endpoint_url=endpoint_url,
319
+ token=token,
320
+ location_name=name,
321
+ )
322
+ return ObjectResult(result)
323
+
324
+
325
+ def _refresh_code_locations(app_name: str | None) -> CommandResult:
326
+ manager = DagflowCliManager()
327
+ result = reload_workspace(
328
+ endpoint_url=manager.dagster_endpoint_url(app_name),
329
+ token=manager.session_token(),
330
+ )
331
+ return ObjectResult(result)
332
+
333
+
334
+ @code_location_app.command("delete", requires_connection=True, requires_global_options=True)
335
+ @with_output
336
+ def delete(
337
+ name: str = typer.Argument(..., help="Dagflow code location name"),
338
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
339
+ yes: bool = typer.Option(False, "--yes", help="Skip confirmation"),
340
+ **options: Any,
341
+ ) -> CommandResult:
342
+ """Delete a Dagflow code location."""
343
+ return _delete_code_location(name, app_name, yes)
344
+
345
+
346
+ @code_location_app.command("reload", requires_connection=True, requires_global_options=True)
347
+ @with_output
348
+ def reload(
349
+ name: str = typer.Argument(..., help="Dagflow code location name"),
350
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
351
+ blocking: bool = typer.Option(True, "--blocking/--no-blocking", help="Wait for code location readiness"),
352
+ **options: Any,
353
+ ) -> CommandResult:
354
+ """Reload a Dagflow code location in Dagster."""
355
+ return _reload_code_location(name, app_name, blocking)
356
+
357
+
358
+ @code_location_app.command("reload-all", requires_connection=True, requires_global_options=True)
359
+ @with_output
360
+ def reload_all(
361
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
362
+ **options: Any,
363
+ ) -> CommandResult:
364
+ """Reload all Dagflow code locations in Dagster."""
365
+ return _refresh_code_locations(app_name)
366
+
367
+
368
+ @code_location_app.command("list", requires_connection=True, requires_global_options=True)
369
+ @with_output
370
+ def list_locations(
371
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
372
+ **options: Any,
373
+ ) -> CommandResult:
374
+ """List Dagflow code locations."""
375
+ cursor = DagflowCliManager().list_code_locations(app_name)
376
+ return SingleQueryResult(cursor)
377
+
378
+
379
+ def _get_code_location(name: str, app_name: str | None) -> CommandResult:
380
+ return ObjectResult(DagflowCliManager().code_location_status(app_name, name))
381
+
382
+
383
+ @code_location_app.command("get", requires_connection=True, requires_global_options=True)
384
+ @with_output
385
+ def get(
386
+ name: str = typer.Argument(..., help="Dagflow code location name"),
387
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
388
+ **options: Any,
389
+ ) -> CommandResult:
390
+ """Get a Dagflow code location."""
391
+ return _get_code_location(name, app_name)
392
+
393
+
394
+ @job_app.command("launch", requires_connection=True, requires_global_options=True)
395
+ @with_output
396
+ def launch(
397
+ job_name: str = typer.Option(..., "--job", "-j", help="Dagster job name"),
398
+ location_name: str = typer.Option(..., "--location", "-l", help="Dagflow code location name"),
399
+ app_name: str | None = typer.Option(None, "--app-name", help="Installed Dagflow application name"),
400
+ repository_name: str = typer.Option("__repository__", "--repository", help="Dagster repository name"),
401
+ config: Path | None = typer.Option(None, "--config", help="Run config YAML file"),
402
+ tag: list[str] | None = typer.Option(None, "--tag", help="Run tag as KEY=VALUE; may be repeated"),
403
+ **options: Any,
404
+ ) -> CommandResult:
405
+ """Launch a Dagster job using Dagflow's configured run launcher."""
406
+ manager = DagflowCliManager()
407
+ execution_params = build_launch_execution_params(
408
+ job_name=job_name,
409
+ location_name=location_name,
410
+ repository_name=repository_name,
411
+ run_config=load_run_config(config),
412
+ tags=parse_tags(tuple(tag or ())),
413
+ )
414
+ result = launch_job(
415
+ endpoint_url=manager.dagster_endpoint_url(app_name),
416
+ token=manager.session_token(),
417
+ execution_params=execution_params,
418
+ )
419
+ return ObjectResult({"run_id": result.run_id, "status": "LAUNCHED"})
420
+
421
+
422
+ app.add_typer(code_location_app)
423
+ app.add_typer(job_app)
dagflow_cli/launch.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import yaml
11
+
12
+
13
+ DEFAULT_REPOSITORY_NAME = "__repository__"
14
+ LAUNCH_MUTATION = """
15
+ mutation Launch($executionParams: ExecutionParams!) {
16
+ launchPipelineExecution(executionParams: $executionParams) {
17
+ __typename
18
+ ... on LaunchRunSuccess { run { runId } }
19
+ ... on PythonError { message stack }
20
+ ... on RunConfigValidationInvalid { errors { message } }
21
+ ... on PipelineNotFoundError { message }
22
+ }
23
+ }
24
+ """
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LaunchResult:
29
+ run_id: str
30
+ response_type: str
31
+
32
+
33
+ def build_launch_execution_params(
34
+ job_name: str,
35
+ location_name: str,
36
+ repository_name: str = DEFAULT_REPOSITORY_NAME,
37
+ run_config: dict[str, Any] | None = None,
38
+ tags: dict[str, str] | None = None,
39
+ ) -> dict[str, Any]:
40
+ if not job_name:
41
+ raise ValueError("job name is required")
42
+ if not location_name:
43
+ raise ValueError("code location name is required")
44
+ return {
45
+ "selector": {
46
+ "repositoryLocationName": location_name,
47
+ "repositoryName": repository_name,
48
+ "pipelineName": job_name,
49
+ },
50
+ "runConfigData": run_config or {},
51
+ "mode": "default",
52
+ "executionMetadata": {
53
+ "tags": [{"key": key, "value": value} for key, value in (tags or {}).items()]
54
+ },
55
+ }
56
+
57
+
58
+ def load_run_config(path: Path | None) -> dict[str, Any]:
59
+ if path is None:
60
+ return {}
61
+ loaded = yaml.safe_load(path.expanduser().read_text())
62
+ if loaded is None:
63
+ return {}
64
+ if not isinstance(loaded, dict):
65
+ raise ValueError("run config YAML must contain a mapping at the document root")
66
+ return loaded
67
+
68
+
69
+ def parse_tags(values: tuple[str, ...]) -> dict[str, str]:
70
+ tags: dict[str, str] = {}
71
+ for value in values:
72
+ if "=" not in value:
73
+ raise ValueError("tags must use KEY=VALUE syntax")
74
+ key, tag_value = value.split("=", 1)
75
+ if not key:
76
+ raise ValueError("tag keys cannot be empty")
77
+ tags[key] = tag_value
78
+ return tags
79
+
80
+
81
+ def launch_job(
82
+ endpoint_url: str,
83
+ token: str,
84
+ execution_params: dict[str, Any],
85
+ post_json: Callable[[str, dict[str, Any], str], dict[str, Any]] | None = None,
86
+ ) -> LaunchResult:
87
+ payload = {"query": LAUNCH_MUTATION, "variables": {"executionParams": execution_params}}
88
+ response = (post_json or _post_json)(f"{endpoint_url.rstrip('/')}/graphql", payload, token)
89
+ if response.get("errors"):
90
+ raise RuntimeError(f"Dagster GraphQL launch failed: {response['errors']}")
91
+ result = response.get("data", {}).get("launchPipelineExecution")
92
+ if not isinstance(result, dict):
93
+ raise RuntimeError(f"Dagster GraphQL launch returned an unexpected response: {response}")
94
+ if result.get("__typename") == "LaunchRunSuccess":
95
+ run_id = result.get("run", {}).get("runId")
96
+ if not run_id:
97
+ raise RuntimeError(f"Dagster GraphQL launch response did not include a run id: {result}")
98
+ return LaunchResult(run_id=str(run_id), response_type="LaunchRunSuccess")
99
+ raise RuntimeError(f"Dagster run launch failed: {result}")
100
+
101
+
102
+ def _post_json(url: str, payload: dict[str, Any], token: str) -> dict[str, Any]:
103
+ body = json.dumps(payload).encode("utf-8")
104
+ request = urllib.request.Request(
105
+ url,
106
+ data=body,
107
+ headers={
108
+ "Authorization": f'Snowflake Token="{token}"',
109
+ "Content-Type": "application/json",
110
+ },
111
+ method="POST",
112
+ )
113
+ try:
114
+ with urllib.request.urlopen(request, timeout=120) as response:
115
+ return json.loads(response.read().decode("utf-8"))
116
+ except urllib.error.HTTPError as exc:
117
+ response_body = exc.read().decode("utf-8", errors="replace")
118
+ raise RuntimeError(f"Dagster GraphQL launch request failed: HTTP {exc.code}: {response_body}") from exc