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.
dagflow_cli/pex.py ADDED
@@ -0,0 +1,523 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import zipfile
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import ContextManager, Iterator, Sequence
17
+
18
+ from packaging.requirements import InvalidRequirement, Requirement
19
+ from packaging.utils import InvalidWheelFilename, canonicalize_name, parse_wheel_filename
20
+
21
+ try:
22
+ import tomllib
23
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
24
+ import tomli as tomllib # type: ignore[no-redef]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PexArtifacts:
29
+ source_pex: Path
30
+ deps_pex: Path
31
+
32
+
33
+ DEFAULT_PYTHON_VERSION = "3.11"
34
+ SUPPORTED_PYTHON_VERSIONS = {"3.11", "3.12"}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PexBuildConfig:
39
+ project_path: Path
40
+ output_dir: Path
41
+ python_version: str = DEFAULT_PYTHON_VERSION
42
+ python: str = sys.executable
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class DependencyBuildInputs:
47
+ requirements: tuple[str, ...]
48
+ direct_requirements: tuple[str, ...]
49
+ local_packages: tuple[Path, ...]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RequirementEntry:
54
+ value: str
55
+ relative_to: Path
56
+
57
+
58
+ SOURCE_IGNORED_PATTERNS = (
59
+ ".git",
60
+ ".venv",
61
+ ".pytest_cache",
62
+ ".tox",
63
+ ".nox",
64
+ ".mypy_cache",
65
+ ".cache",
66
+ "__pycache__",
67
+ "*.pyc",
68
+ "*.egg-info",
69
+ ".dagflow-*",
70
+ "build",
71
+ "dist",
72
+ )
73
+
74
+
75
+ def build_pex_artifacts(config: PexBuildConfig) -> PexArtifacts:
76
+ project_path = config.project_path.expanduser().resolve()
77
+ if not project_path.exists():
78
+ raise ValueError(f"project path does not exist: {project_path}")
79
+ target_python = _target_python(config.python_version)
80
+
81
+ output_dir = config.output_dir.expanduser().resolve()
82
+ output_dir.mkdir(parents=True, exist_ok=True)
83
+ source_pex = output_dir / "source.pex"
84
+ deps_pex = output_dir / "deps.pex"
85
+
86
+ inputs = _collect_dependency_inputs(project_path, target_python)
87
+ _validate_runtime_dependencies(inputs.direct_requirements)
88
+ with _requirements_file(inputs.requirements, project_path) as requirements:
89
+ with _staged_source_directories(
90
+ project_path,
91
+ inputs.local_packages,
92
+ target_python,
93
+ output_dir,
94
+ ) as source_directories:
95
+ _run_pex(
96
+ _source_pex_command(
97
+ config.python,
98
+ target_python,
99
+ source_directories,
100
+ source_pex,
101
+ ),
102
+ cwd=project_path,
103
+ )
104
+ with _complete_platform_file(config.python_version) as complete_platform:
105
+ _run_pex(
106
+ _deps_pex_command(config.python, complete_platform, requirements, deps_pex),
107
+ cwd=project_path,
108
+ )
109
+ _validate_deps_pex(deps_pex)
110
+
111
+ return PexArtifacts(source_pex=source_pex, deps_pex=deps_pex)
112
+
113
+
114
+ def default_output_dir(location_name: str, version: str) -> Path:
115
+ return Path("dist") / "dagflow" / location_name / version
116
+
117
+
118
+ def default_version() -> str:
119
+ try:
120
+ result = subprocess.run(
121
+ ["git", "rev-parse", "--short=12", "HEAD"],
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ except (OSError, subprocess.CalledProcessError):
127
+ from datetime import datetime, timezone
128
+
129
+ return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
130
+ return result.stdout.strip()
131
+
132
+
133
+ def _target_python(python_version: str) -> str:
134
+ if python_version not in SUPPORTED_PYTHON_VERSIONS:
135
+ raise ValueError(
136
+ f"unsupported python version {python_version}; supported versions are "
137
+ + ", ".join(sorted(SUPPORTED_PYTHON_VERSIONS))
138
+ )
139
+ current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
140
+ if python_version == current_version:
141
+ return sys.executable
142
+ executable = f"python{python_version}"
143
+ resolved = shutil.which(executable)
144
+ if resolved is None:
145
+ raise ValueError(
146
+ f"{executable} was not found on PATH; install Python {python_version} "
147
+ f"or choose a runtime version available in this environment"
148
+ )
149
+ return resolved
150
+
151
+
152
+ @contextmanager
153
+ def _requirements_file(requirements: Sequence[str], project_path: Path) -> Iterator[Path]:
154
+ generated = tempfile.NamedTemporaryFile(
155
+ "w",
156
+ encoding="utf-8",
157
+ suffix="-requirements.txt",
158
+ prefix=".dagflow-",
159
+ dir=project_path,
160
+ delete=False,
161
+ )
162
+ path = Path(generated.name)
163
+ try:
164
+ generated.write("\n".join(requirements))
165
+ generated.write("\n")
166
+ generated.close()
167
+ yield path
168
+ finally:
169
+ generated.close()
170
+ path.unlink(missing_ok=True)
171
+
172
+
173
+ @contextmanager
174
+ def _temporary_json_file(value: object) -> Iterator[Path]:
175
+ generated = tempfile.NamedTemporaryFile(
176
+ "w", encoding="utf-8", suffix="-complete-platform.json", delete=False
177
+ )
178
+ path = Path(generated.name)
179
+ try:
180
+ json.dump(value, generated)
181
+ generated.close()
182
+ yield path
183
+ finally:
184
+ generated.close()
185
+ path.unlink(missing_ok=True)
186
+
187
+
188
+ def _runtime_complete_platform(python_version: str) -> dict[str, object]:
189
+ manifest = json.loads(
190
+ resources.files("dagflow_cli")
191
+ .joinpath("runtime-platforms.json")
192
+ .read_text(encoding="utf-8")
193
+ )[python_version]
194
+ return manifest["complete_platform"]
195
+
196
+
197
+ def _complete_platform_file(python_version: str) -> ContextManager[Path]:
198
+ return _temporary_json_file(_runtime_complete_platform(python_version))
199
+
200
+
201
+ def _deps_pex_command(python: str, complete_platform: Path, requirements: Path, output: Path) -> list[str]:
202
+ return [
203
+ python,
204
+ "-m",
205
+ "pex",
206
+ "--complete-platform",
207
+ str(complete_platform),
208
+ "-r",
209
+ str(requirements),
210
+ "--pip-version",
211
+ "latest-compatible",
212
+ "--resolver-version",
213
+ "pip-2020-resolver",
214
+ "--inherit-path=false",
215
+ "--no-strip-pex-env",
216
+ "-o",
217
+ str(output),
218
+ ]
219
+
220
+
221
+ def _source_pex_command(
222
+ python: str,
223
+ target_python: str,
224
+ source_directories: Sequence[Path],
225
+ output: Path,
226
+ ) -> list[str]:
227
+ source_args = [arg for path in source_directories for arg in ("-D", str(path))]
228
+ return [
229
+ python,
230
+ "-m",
231
+ "pex",
232
+ "--python",
233
+ target_python,
234
+ *source_args,
235
+ "--inherit-path=false",
236
+ "--no-strip-pex-env",
237
+ "-o",
238
+ str(output),
239
+ ]
240
+
241
+
242
+ def _run_pex(command: Sequence[str], cwd: Path) -> None:
243
+ env = {**os.environ, "PEX_ROOT": os.environ.get("PEX_ROOT", str(Path.home() / ".pex"))}
244
+ subprocess.run(command, cwd=cwd, env=env, check=True)
245
+
246
+
247
+ @contextmanager
248
+ def _staged_source_directories(
249
+ project_path: Path,
250
+ local_packages: Sequence[Path],
251
+ python: str,
252
+ output_dir: Path | None = None,
253
+ ) -> Iterator[tuple[Path, ...]]:
254
+ with tempfile.TemporaryDirectory(prefix="dagflow-source-") as temp_dir:
255
+ staging_root = Path(temp_dir)
256
+ build_dir = staging_root / "installed"
257
+ source_dir = staging_root / "source"
258
+ build_dir.mkdir()
259
+
260
+ for local_package in (*local_packages, project_path):
261
+ if _is_buildable_project(local_package):
262
+ _install_project_source(local_package, build_dir, python)
263
+
264
+ base_ignore = shutil.ignore_patterns(*SOURCE_IGNORED_PATTERNS)
265
+
266
+ def ignore(directory: str, names: list[str]) -> set[str]:
267
+ ignored = set(base_ignore(directory, names))
268
+ if output_dir is not None:
269
+ for name in names:
270
+ if (Path(directory) / name).resolve() == output_dir.resolve():
271
+ ignored.add(name)
272
+ return ignored
273
+
274
+ shutil.copytree(
275
+ project_path,
276
+ source_dir,
277
+ ignore=ignore,
278
+ symlinks=True,
279
+ )
280
+ _remove_installed_source_collisions(source_dir, build_dir)
281
+ directories = [source_dir]
282
+ if any(build_dir.iterdir()):
283
+ directories.insert(0, build_dir)
284
+ yield tuple(directories)
285
+
286
+
287
+ def _is_buildable_project(project_path: Path) -> bool:
288
+ return (project_path / "setup.py").exists() or (project_path / "pyproject.toml").exists()
289
+
290
+
291
+ def _remove_installed_source_collisions(source_dir: Path, build_dir: Path) -> None:
292
+ for installed_path in build_dir.rglob("*"):
293
+ if not installed_path.is_file() and not installed_path.is_symlink():
294
+ continue
295
+ source_path = source_dir / installed_path.relative_to(build_dir)
296
+ if source_path.is_file() or source_path.is_symlink():
297
+ source_path.unlink()
298
+
299
+
300
+ def _install_project_source(project_path: Path, build_dir: Path, python: str) -> None:
301
+ try:
302
+ subprocess.run(
303
+ [
304
+ python,
305
+ "-m",
306
+ "pip",
307
+ "install",
308
+ "--target",
309
+ str(build_dir),
310
+ "--no-deps",
311
+ ".",
312
+ ],
313
+ cwd=project_path,
314
+ check=True,
315
+ )
316
+ except subprocess.CalledProcessError as exc:
317
+ raise ValueError(f"could not build local project source at {project_path}") from exc
318
+
319
+
320
+ def _collect_dependency_inputs(project_path: Path, python: str) -> DependencyBuildInputs:
321
+ direct_entries = _project_dependency_entries(project_path, python)
322
+ pending = [(project_path, direct_entries)]
323
+ seen_projects: set[Path] = set()
324
+ local_packages: list[Path] = []
325
+ requirements: list[str] = []
326
+
327
+ while pending:
328
+ current_project, entries = pending.pop()
329
+ current_project = current_project.resolve()
330
+ if current_project in seen_projects:
331
+ continue
332
+ seen_projects.add(current_project)
333
+
334
+ for entry in entries:
335
+ local_path = _local_package_path(entry)
336
+ if local_path is None:
337
+ if entry.value not in requirements:
338
+ requirements.append(entry.value)
339
+ continue
340
+ if local_path == current_project:
341
+ continue
342
+ if not local_path.is_dir():
343
+ raise ValueError(
344
+ f"local package {local_path} referenced by {entry.value!r} is not a directory"
345
+ )
346
+ if local_path not in local_packages:
347
+ local_packages.append(local_path)
348
+ pending.append((local_path, _project_dependency_entries(local_path, python)))
349
+
350
+ return DependencyBuildInputs(
351
+ requirements=tuple(requirements),
352
+ direct_requirements=tuple(entry.value for entry in direct_entries),
353
+ local_packages=tuple(local_packages),
354
+ )
355
+
356
+
357
+ def _project_dependency_entries(project_path: Path, python: str) -> list[RequirementEntry]:
358
+ entries: list[RequirementEntry] = []
359
+ requirements = project_path / "requirements.txt"
360
+ if requirements.exists():
361
+ entries.extend(_requirements_file_entries(requirements))
362
+ entries.extend(
363
+ RequirementEntry(value=value, relative_to=project_path)
364
+ for value in _setup_py_dependencies(project_path, python)
365
+ )
366
+ entries.extend(
367
+ RequirementEntry(value=value, relative_to=project_path)
368
+ for value in _pyproject_dependencies(project_path)
369
+ )
370
+ return entries
371
+
372
+
373
+ def _validate_runtime_dependencies(dependencies: Sequence[str]) -> None:
374
+ names = {_requirement_name(dependency) for dependency in dependencies}
375
+ names.discard(None)
376
+ missing = [name for name in ("dagster", "dagster-postgres") if name not in names]
377
+ if missing:
378
+ required = ", ".join(missing)
379
+ raise ValueError(
380
+ f"deps.pex must include direct project requirement(s): {required}. "
381
+ "The code-location image does not provide Dagster or PostgreSQL storage classes."
382
+ )
383
+
384
+
385
+ def _requirements_file_entries(
386
+ path: Path,
387
+ seen: set[Path] | None = None,
388
+ ) -> list[RequirementEntry]:
389
+ resolved = path.resolve()
390
+ visited = seen or set()
391
+ if resolved in visited:
392
+ return []
393
+ visited.add(resolved)
394
+
395
+ entries: list[RequirementEntry] = []
396
+ for raw_line in _logical_requirement_lines(resolved):
397
+ line = re.sub(r"(^#|\s+#).*", "", raw_line).strip()
398
+ if not line:
399
+ continue
400
+ parts = line.split(maxsplit=1)
401
+ if parts[0] in {"-r", "--requirement"} and len(parts) == 2:
402
+ entries.extend(_requirements_file_entries(resolved.parent / parts[1], visited))
403
+ elif parts[0].startswith("-r") and len(parts[0]) > 2:
404
+ entries.extend(_requirements_file_entries(resolved.parent / parts[0][2:], visited))
405
+ elif line != ".":
406
+ entries.append(RequirementEntry(value=line, relative_to=resolved.parent))
407
+ return entries
408
+
409
+
410
+ def _logical_requirement_lines(path: Path) -> list[str]:
411
+ lines: list[str] = []
412
+ current = ""
413
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
414
+ stripped = raw_line.rstrip()
415
+ if stripped.endswith("\\"):
416
+ current += stripped[:-1]
417
+ else:
418
+ lines.append(current + stripped)
419
+ current = ""
420
+ if current:
421
+ lines.append(current)
422
+ return lines
423
+
424
+
425
+ def _local_package_path(entry: RequirementEntry) -> Path | None:
426
+ value = entry.value.strip()
427
+ if value.startswith("-e ") or value.startswith("--editable "):
428
+ value = value.split(maxsplit=1)[1]
429
+ if not value.startswith(("./", "../", "/")):
430
+ return None
431
+ return (entry.relative_to / value).resolve()
432
+
433
+
434
+ def _requirement_name(value: str) -> str | None:
435
+ try:
436
+ return canonicalize_name(Requirement(value).name)
437
+ except InvalidRequirement:
438
+ return None
439
+
440
+
441
+ def _validate_deps_pex(path: Path) -> None:
442
+ try:
443
+ with zipfile.ZipFile(path) as archive:
444
+ pex_info = json.loads(archive.read("PEX-INFO"))
445
+ except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as exc:
446
+ raise ValueError(f"could not inspect built deps.pex: {exc}") from exc
447
+
448
+ distribution_names: set[str] = set()
449
+ for filename in pex_info.get("distributions", {}):
450
+ try:
451
+ name, _version, _build, _tags = parse_wheel_filename(filename)
452
+ except InvalidWheelFilename:
453
+ continue
454
+ distribution_names.add(canonicalize_name(name))
455
+
456
+ missing = [name for name in ("dagster", "dagster-postgres") if name not in distribution_names]
457
+ if missing:
458
+ raise ValueError(
459
+ "built deps.pex does not contain required distribution(s): " + ", ".join(missing)
460
+ )
461
+
462
+
463
+ def _setup_py_dependencies(project_path: Path, python: str) -> list[str]:
464
+ setup_py = project_path / "setup.py"
465
+ if not setup_py.exists():
466
+ return []
467
+
468
+ with tempfile.TemporaryDirectory() as temp_dir:
469
+ result = subprocess.run(
470
+ [python, str(setup_py), "egg_info", f"--egg-base={temp_dir}"],
471
+ cwd=project_path,
472
+ capture_output=True,
473
+ text=True,
474
+ check=False,
475
+ )
476
+ if result.returncode:
477
+ raise ValueError(
478
+ "could not inspect setup.py dependencies: "
479
+ + result.stdout
480
+ + result.stderr
481
+ )
482
+
483
+ distributions = list(importlib.metadata.distributions(path=[temp_dir]))
484
+ if len(distributions) != 1:
485
+ raise ValueError(f"could not find generated distribution metadata for {setup_py}")
486
+ dependencies: list[str] = []
487
+ for value in distributions[0].requires or []:
488
+ requirement = Requirement(value)
489
+ if requirement.marker and re.search(r"\bextra\b", str(requirement.marker)):
490
+ continue
491
+ dependencies.append(value)
492
+ return dependencies
493
+
494
+
495
+ def _pyproject_dependencies(project_path: Path) -> list[str]:
496
+ pyproject = project_path / "pyproject.toml"
497
+ if not pyproject.exists():
498
+ return []
499
+
500
+ try:
501
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
502
+ except Exception as exc:
503
+ raise ValueError(f"could not parse pyproject.toml: {exc}") from exc
504
+
505
+ deps: list[str] = []
506
+ project = data.get("project", {})
507
+ deps.extend(str(dep) for dep in project.get("dependencies", []))
508
+
509
+ poetry = data.get("tool", {}).get("poetry", {})
510
+ for dep_name, dep_spec in poetry.get("dependencies", {}).items():
511
+ if dep_name == "python":
512
+ continue
513
+ deps.append(_poetry_dependency(dep_name, dep_spec))
514
+ return [dep for dep in deps if dep]
515
+
516
+
517
+ def _poetry_dependency(dep_name: str, dep_spec: object) -> str:
518
+ if isinstance(dep_spec, str):
519
+ return f"{dep_name}{dep_spec}"
520
+ if isinstance(dep_spec, dict):
521
+ version = dep_spec.get("version", "")
522
+ return f"{dep_name}{version}" if version else dep_name
523
+ return dep_name
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from snowflake.cli.api.plugins.command import (
4
+ SNOWCLI_ROOT_COMMAND_PATH,
5
+ CommandSpec,
6
+ CommandType,
7
+ plugin_hook_impl,
8
+ )
9
+
10
+ from dagflow_cli import commands
11
+
12
+
13
+ @plugin_hook_impl
14
+ def command_spec() -> CommandSpec:
15
+ return CommandSpec(
16
+ parent_command_path=SNOWCLI_ROOT_COMMAND_PATH,
17
+ command_type=CommandType.COMMAND_GROUP,
18
+ typer_instance=commands.app.create_instance(),
19
+ )