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/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
+ )