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/__init__.py +1 -0
- dagflow_cli/commands.py +423 -0
- dagflow_cli/launch.py +118 -0
- dagflow_cli/manager.py +283 -0
- dagflow_cli/pex.py +523 -0
- dagflow_cli/plugin_spec.py +19 -0
- dagflow_cli/runtime-platforms.json +1221 -0
- dagflow_cli/workspace.py +215 -0
- dagflow_cli_plugin-0.1.0.dist-info/METADATA +30 -0
- dagflow_cli_plugin-0.1.0.dist-info/RECORD +13 -0
- dagflow_cli_plugin-0.1.0.dist-info/WHEEL +5 -0
- dagflow_cli_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- dagflow_cli_plugin-0.1.0.dist-info/top_level.txt +1 -0
dagflow_cli/manager.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from snowflake.cli.api.sql_execution import SqlExecutionMixin
|
|
13
|
+
except ModuleNotFoundError: # pragma: no cover - exercised only without snowflake-cli installed
|
|
14
|
+
class SqlExecutionMixin: # type: ignore[no-redef]
|
|
15
|
+
def execute_query(self, query: str) -> Any:
|
|
16
|
+
raise RuntimeError("snowflake-cli is required to execute Dagflow CLI commands")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CODE_LOCATION_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|
20
|
+
VERSION_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
21
|
+
MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
|
22
|
+
PYTHON_FILE_RE = re.compile(r"^[A-Za-z0-9_./-]+\.py$")
|
|
23
|
+
IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Entrypoint:
|
|
28
|
+
type: str
|
|
29
|
+
value: str
|
|
30
|
+
|
|
31
|
+
def as_payload(self) -> dict[str, str]:
|
|
32
|
+
if self.type == "python_file":
|
|
33
|
+
return {"type": self.type, "path": self.value}
|
|
34
|
+
return {"type": self.type, "name": self.value}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DagflowCliManager(SqlExecutionMixin):
|
|
38
|
+
def dagster_endpoint_sql(self, app_name: str | None) -> str:
|
|
39
|
+
return f"SHOW ENDPOINTS IN SERVICE {self._dagster_service_name(app_name)}"
|
|
40
|
+
|
|
41
|
+
def dagster_endpoint_url(self, app_name: str | None) -> str:
|
|
42
|
+
rows = self._rows_from_cursor(self.execute_query(self.dagster_endpoint_sql(app_name)))
|
|
43
|
+
ingress_url = next(
|
|
44
|
+
(
|
|
45
|
+
str(row.get("ingress_url") or row.get("INGRESS_URL"))
|
|
46
|
+
for row in rows
|
|
47
|
+
if str(row.get("name") or row.get("NAME")).lower() == "dagster-ui"
|
|
48
|
+
),
|
|
49
|
+
"",
|
|
50
|
+
)
|
|
51
|
+
if not ingress_url:
|
|
52
|
+
raise RuntimeError("Dagster endpoint dagster-ui was not found")
|
|
53
|
+
if ingress_url.startswith("http://") or ingress_url.startswith("https://"):
|
|
54
|
+
return ingress_url.rstrip("/")
|
|
55
|
+
return f"https://{ingress_url.rstrip('/')}"
|
|
56
|
+
|
|
57
|
+
def session_token(self) -> str:
|
|
58
|
+
try:
|
|
59
|
+
self.execute_query("ALTER SESSION SET PYTHON_CONNECTOR_QUERY_RESULT_FORMAT = 'json'")
|
|
60
|
+
return str(self._conn._rest._token_request("ISSUE")["data"]["sessionToken"])
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise RuntimeError("Could not issue a Snowflake session token for the Dagster endpoint") from exc
|
|
63
|
+
|
|
64
|
+
def put_source_pex_sql(self, app_name: str | None, location_name: str, version: str, path: Path) -> str:
|
|
65
|
+
return self._put_sql(app_name, location_name, version, path, "source.pex")
|
|
66
|
+
|
|
67
|
+
def put_deps_pex_sql(self, app_name: str | None, location_name: str, version: str, path: Path) -> str:
|
|
68
|
+
return self._put_sql(app_name, location_name, version, path, "deps.pex")
|
|
69
|
+
|
|
70
|
+
def list_code_locations_sql(self, app_name: str | None) -> str:
|
|
71
|
+
return f"SHOW SERVICES IN SCHEMA {self._code_location_schema(app_name)}"
|
|
72
|
+
|
|
73
|
+
def describe_code_location_sql(self, app_name: str | None, location_name: str) -> str:
|
|
74
|
+
self._validate_code_location(location_name)
|
|
75
|
+
return f"DESCRIBE SERVICE {self._service_name(app_name, location_name)}"
|
|
76
|
+
|
|
77
|
+
def show_code_location_containers_sql(self, app_name: str | None, location_name: str) -> str:
|
|
78
|
+
self._validate_code_location(location_name)
|
|
79
|
+
return f"SHOW SERVICE CONTAINERS IN SERVICE {self._service_name(app_name, location_name)}"
|
|
80
|
+
|
|
81
|
+
def code_location_status(self, app_name: str | None, location_name: str) -> dict[str, Any]:
|
|
82
|
+
self._validate_code_location(location_name)
|
|
83
|
+
rows = self._rows_from_cursor(self.execute_query(self.list_code_locations_sql(app_name)))
|
|
84
|
+
expected = location_name.upper()
|
|
85
|
+
row = next(
|
|
86
|
+
(row for row in rows if str(row.get("name") or row.get("NAME")).upper() == expected),
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
if row is None:
|
|
90
|
+
raise RuntimeError(f"Code location {location_name} was not found")
|
|
91
|
+
comment = row.get("comment") or row.get("COMMENT")
|
|
92
|
+
desired: Any = None
|
|
93
|
+
if comment:
|
|
94
|
+
try:
|
|
95
|
+
desired = json.loads(str(comment))
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
desired = str(comment)
|
|
98
|
+
return {
|
|
99
|
+
"name": row.get("name") or row.get("NAME"),
|
|
100
|
+
"status": row.get("status") or row.get("STATUS"),
|
|
101
|
+
"database_name": row.get("database_name") or row.get("DATABASE_NAME"),
|
|
102
|
+
"schema_name": row.get("schema_name") or row.get("SCHEMA_NAME"),
|
|
103
|
+
"dns_name": row.get("dns_name") or row.get("DNS_NAME"),
|
|
104
|
+
"desired": desired,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def upload_artifacts(
|
|
108
|
+
self,
|
|
109
|
+
app_name: str | None,
|
|
110
|
+
location_name: str,
|
|
111
|
+
version: str,
|
|
112
|
+
source_pex: Path,
|
|
113
|
+
deps_pex: Path,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.execute_query(self.put_source_pex_sql(app_name, location_name, version, source_pex))
|
|
116
|
+
self.execute_query(self.put_deps_pex_sql(app_name, location_name, version, deps_pex))
|
|
117
|
+
|
|
118
|
+
def deploy_code_location_api(
|
|
119
|
+
self,
|
|
120
|
+
app_name: str | None,
|
|
121
|
+
location_name: str,
|
|
122
|
+
version: str,
|
|
123
|
+
entrypoint: Entrypoint,
|
|
124
|
+
python_version: str,
|
|
125
|
+
blocking: bool,
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
self._validate_code_location(location_name)
|
|
128
|
+
self._validate_version(version)
|
|
129
|
+
self._validate_entrypoint(entrypoint)
|
|
130
|
+
return self._request_code_location_api(
|
|
131
|
+
app_name,
|
|
132
|
+
"PUT",
|
|
133
|
+
f"/code-locations/{location_name}",
|
|
134
|
+
{
|
|
135
|
+
"version": version,
|
|
136
|
+
"entrypoint": entrypoint.as_payload(),
|
|
137
|
+
"python_version": python_version,
|
|
138
|
+
"blocking": blocking,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def remove_code_location_api(self, app_name: str | None, location_name: str) -> dict[str, Any]:
|
|
143
|
+
self._validate_code_location(location_name)
|
|
144
|
+
return self._request_code_location_api(
|
|
145
|
+
app_name,
|
|
146
|
+
"DELETE",
|
|
147
|
+
f"/code-locations/{location_name}",
|
|
148
|
+
None,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def list_code_locations(self, app_name: str | None) -> Any:
|
|
152
|
+
return self.execute_query(self.list_code_locations_sql(app_name))
|
|
153
|
+
|
|
154
|
+
def describe_code_location(self, app_name: str | None, location_name: str) -> Any:
|
|
155
|
+
return self.execute_query(self.describe_code_location_sql(app_name, location_name))
|
|
156
|
+
|
|
157
|
+
def code_location_service_exists(self, app_name: str | None, location_name: str) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
self.execute_query(self.describe_code_location_sql(app_name, location_name))
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
if self._is_missing_service_error(exc):
|
|
162
|
+
return False
|
|
163
|
+
raise
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def show_code_location_containers(self, app_name: str | None, location_name: str) -> Any:
|
|
167
|
+
return self.execute_query(self.show_code_location_containers_sql(app_name, location_name))
|
|
168
|
+
|
|
169
|
+
def _put_sql(
|
|
170
|
+
self,
|
|
171
|
+
app_name: str | None,
|
|
172
|
+
location_name: str,
|
|
173
|
+
version: str,
|
|
174
|
+
path: Path,
|
|
175
|
+
artifact_name: str,
|
|
176
|
+
) -> str:
|
|
177
|
+
self._validate_code_location(location_name)
|
|
178
|
+
self._validate_version(version)
|
|
179
|
+
if path.name != artifact_name:
|
|
180
|
+
raise ValueError(f"{artifact_name} path must end with {artifact_name}")
|
|
181
|
+
stage_path = f"{self._pex_stage(app_name)}/{location_name.lower()}/{version}/"
|
|
182
|
+
return f"PUT file://{path.expanduser().resolve().as_posix()} {stage_path} AUTO_COMPRESS=FALSE OVERWRITE=TRUE"
|
|
183
|
+
|
|
184
|
+
def _request_code_location_api(
|
|
185
|
+
self,
|
|
186
|
+
app_name: str | None,
|
|
187
|
+
method: str,
|
|
188
|
+
path: str,
|
|
189
|
+
payload: dict[str, Any] | None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
body = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
192
|
+
request = urllib.request.Request(
|
|
193
|
+
f"{self.dagster_endpoint_url(app_name).rstrip('/')}{path}",
|
|
194
|
+
data=body,
|
|
195
|
+
headers={
|
|
196
|
+
"Authorization": f'Snowflake Token="{self.session_token()}"',
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
},
|
|
199
|
+
method=method,
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
with urllib.request.urlopen(request, timeout=360) as response:
|
|
203
|
+
return json.loads(response.read().decode("utf-8"))
|
|
204
|
+
except urllib.error.HTTPError as exc:
|
|
205
|
+
response_body = exc.read().decode("utf-8", errors="replace")
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"Dagflow code-location request failed: HTTP {exc.code}: {response_body}"
|
|
208
|
+
) from exc
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def _validate_code_location(cls, value: str) -> None:
|
|
212
|
+
if not CODE_LOCATION_RE.fullmatch(value):
|
|
213
|
+
raise ValueError("code location names must start with a letter and contain only letters, numbers, and underscores")
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def _validate_version(cls, value: str) -> None:
|
|
217
|
+
if not VERSION_RE.fullmatch(value):
|
|
218
|
+
raise ValueError("versions must contain only letters, numbers, underscores, dots, and dashes")
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def _validate_entrypoint(cls, entrypoint: Entrypoint) -> None:
|
|
222
|
+
if entrypoint.type not in {"module", "package", "python_file"}:
|
|
223
|
+
raise ValueError(f"unsupported entrypoint type: {entrypoint.type}")
|
|
224
|
+
if entrypoint.type == "python_file":
|
|
225
|
+
if not PYTHON_FILE_RE.fullmatch(entrypoint.value) or ".." in entrypoint.value:
|
|
226
|
+
raise ValueError("python_file entrypoints must be relative .py files without '..'")
|
|
227
|
+
return
|
|
228
|
+
if not MODULE_RE.fullmatch(entrypoint.value):
|
|
229
|
+
raise ValueError("module and package entrypoints must be valid dotted Python names")
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def _identifier(cls, value: str) -> str:
|
|
233
|
+
if not IDENTIFIER_RE.fullmatch(value):
|
|
234
|
+
raise ValueError(f"invalid Snowflake identifier: {value}")
|
|
235
|
+
return value.upper()
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def _code_location_schema(cls, app_name: str | None) -> str:
|
|
239
|
+
if app_name:
|
|
240
|
+
return f"{cls._identifier(app_name)}.code_locations"
|
|
241
|
+
return "code_locations"
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def _service_name(cls, app_name: str | None, location_name: str) -> str:
|
|
245
|
+
return f"{cls._code_location_schema(app_name)}.{cls._identifier(location_name)}"
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def _dagster_service_name(cls, app_name: str | None) -> str:
|
|
249
|
+
if app_name:
|
|
250
|
+
return f"{cls._identifier(app_name)}.core.dagster"
|
|
251
|
+
return "core.dagster"
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def _pex_stage(cls, app_name: str | None) -> str:
|
|
255
|
+
if app_name:
|
|
256
|
+
return f"@{cls._identifier(app_name)}.core.pex"
|
|
257
|
+
return "@core.pex"
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _rows_from_cursor(cursor: Any) -> list[dict[str, Any]]:
|
|
261
|
+
rows = cursor.fetchall()
|
|
262
|
+
if not rows:
|
|
263
|
+
return []
|
|
264
|
+
first = rows[0]
|
|
265
|
+
if isinstance(first, dict):
|
|
266
|
+
return list(rows)
|
|
267
|
+
description = getattr(cursor, "description", None)
|
|
268
|
+
if not description:
|
|
269
|
+
return []
|
|
270
|
+
columns = [str(column[0]).lower() for column in description]
|
|
271
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _is_missing_service_error(exc: Exception) -> bool:
|
|
275
|
+
message = str(exc).lower()
|
|
276
|
+
if "not authorized" in message and "not exist" not in message and "not found" not in message:
|
|
277
|
+
return False
|
|
278
|
+
return (
|
|
279
|
+
"does not exist" in message
|
|
280
|
+
or "not exist" in message
|
|
281
|
+
or "not found" in message
|
|
282
|
+
or "doesn't exist" in message
|
|
283
|
+
)
|