prefect-cloud 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.
File without changes
prefect_cloud/auth.py ADDED
@@ -0,0 +1,419 @@
1
+ import json
2
+ import os
3
+ import socket
4
+ import threading
5
+ import webbrowser
6
+ from contextlib import asynccontextmanager, contextmanager
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from pathlib import Path
9
+ from queue import Queue
10
+ from typing import Any, AsyncGenerator, Generator, Sequence
11
+ from urllib.parse import parse_qs, quote, urlparse
12
+ from uuid import UUID
13
+
14
+ import toml
15
+ from pydantic import BaseModel, TypeAdapter
16
+
17
+ from prefect_cloud.client import PrefectCloudClient, SyncPrefectCloudClient
18
+ from prefect_cloud.utilities.tui import prompt_select_from_list
19
+
20
+
21
+ def _get_cloud_urls() -> tuple[str, str]:
22
+ """Get the appropriate Cloud UI and API URLs based on environment"""
23
+
24
+ env = os.environ.get("CLOUD_ENV")
25
+ from_env = os.environ.get("PREFECT_CLOUD_API_URL")
26
+
27
+ if from_env:
28
+ cloud_api_url = from_env
29
+ elif env in ("prd", "prod", None):
30
+ cloud_api_url = "https://api.prefect.cloud/api"
31
+ elif env == "stg":
32
+ cloud_api_url = "https://api.stg.prefect.dev/api"
33
+ elif env == "dev":
34
+ cloud_api_url = "https://api.prefect.dev/api"
35
+ elif env == "lcl":
36
+ cloud_api_url = "http://localhost:8000/api"
37
+ else:
38
+ raise ValueError(f"Invalid CLOUD_ENV: {env}")
39
+
40
+ if os.environ.get("PREFECT_CLOUD_UI_URL"):
41
+ cloud_ui_url = os.environ["PREFECT_CLOUD_UI_URL"]
42
+ else:
43
+ cloud_ui_url = cloud_api_url.replace("https://api.", "https://app.").replace(
44
+ "/api", ""
45
+ )
46
+
47
+ return cloud_ui_url, cloud_api_url
48
+
49
+
50
+ CLOUD_UI_URL, CLOUD_API_URL = _get_cloud_urls()
51
+
52
+ PREFECT_HOME = Path.home() / ".prefect"
53
+
54
+
55
+ class Account(BaseModel):
56
+ account_id: UUID
57
+ account_name: str
58
+ account_handle: str
59
+
60
+
61
+ class Workspace(BaseModel):
62
+ account_id: UUID
63
+ account_name: str
64
+ account_handle: str
65
+ workspace_id: UUID
66
+ workspace_name: str
67
+ workspace_handle: str
68
+
69
+ @property
70
+ def full_handle(self) -> str:
71
+ return f"{self.account_handle}/{self.workspace_handle}"
72
+
73
+ @property
74
+ def api_url(self) -> str:
75
+ return (
76
+ f"{CLOUD_API_URL}/accounts/{self.account_id}/workspaces/{self.workspace_id}"
77
+ )
78
+
79
+
80
+ class Me(BaseModel):
81
+ id: UUID
82
+ email: str
83
+ first_name: str
84
+ last_name: str
85
+ handle: str
86
+
87
+
88
+ async def login(api_key: str | None = None, workspace_id_or_slug: str | None = None):
89
+ """Logs the user into Prefect Cloud interactively, setting their active profile"""
90
+ if not api_key:
91
+ api_key = get_api_key()
92
+
93
+ if not api_key or not await key_is_valid(api_key):
94
+ api_key = login_interactively()
95
+ if not api_key:
96
+ return
97
+
98
+ workspace: Workspace | None = None
99
+ if workspace_id_or_slug:
100
+ workspace = await lookup_workspace(api_key, workspace_id_or_slug)
101
+
102
+ if not workspace:
103
+ workspace = await prompt_for_workspace(api_key)
104
+ if not workspace:
105
+ return
106
+
107
+ set_cloud_profile(api_key, workspace)
108
+
109
+
110
+ def logout():
111
+ """Logs the user out of Prefect Cloud"""
112
+ remove_cloud_profile()
113
+
114
+
115
+ @asynccontextmanager
116
+ async def cloud_client(api_key: str) -> AsyncGenerator[PrefectCloudClient, None]:
117
+ """Creates a client for the Prefect Cloud API"""
118
+
119
+ async with PrefectCloudClient(api_url=CLOUD_API_URL, api_key=api_key) as client:
120
+ yield client
121
+
122
+
123
+ @contextmanager
124
+ def sync_cloud_client(api_key: str) -> Generator[SyncPrefectCloudClient, None, None]:
125
+ """Creates a client for the Prefect Cloud API"""
126
+ with SyncPrefectCloudClient(api_url=CLOUD_API_URL, api_key=api_key) as client:
127
+ yield client
128
+
129
+
130
+ async def get_prefect_cloud_client() -> PrefectCloudClient:
131
+ _, api_url, api_key = await get_cloud_urls_or_login()
132
+ return PrefectCloudClient(
133
+ api_url=api_url,
134
+ api_key=api_key,
135
+ )
136
+
137
+
138
+ async def get_cloud_urls_or_login() -> tuple[str, str, str]:
139
+ """Gets the cloud UI URL, API URL, and API key"""
140
+ ui_url, api_url, api_key = get_cloud_urls_without_login()
141
+ if not ui_url or not api_url or not api_key:
142
+ await login()
143
+
144
+ ui_url, api_url, api_key = get_cloud_urls_without_login()
145
+ if not ui_url or not api_url or not api_key:
146
+ raise ValueError("No cloud profile found")
147
+
148
+ return ui_url, api_url, api_key
149
+
150
+
151
+ def get_cloud_urls_without_login() -> tuple[str | None, str | None, str | None]:
152
+ """Gets the cloud UI URL, API URL, and API key"""
153
+ api_url: str | None = get_api_url()
154
+ if not api_url:
155
+ return None, None, None
156
+
157
+ ui_url = (
158
+ api_url.replace("https://api.", "https://app.")
159
+ .replace("/api", "")
160
+ .replace("/accounts/", "/account/")
161
+ .replace("/workspaces/", "/workspace/")
162
+ )
163
+
164
+ api_key = get_api_key()
165
+ if not api_key:
166
+ return None, None, None
167
+
168
+ return ui_url, api_url, api_key
169
+
170
+
171
+ def get_api_key_or_login() -> str:
172
+ """Gets a validated API key or logs the user in if no API key is available"""
173
+ api_key = get_api_key()
174
+ if not api_key:
175
+ api_key = login_interactively()
176
+ if not api_key:
177
+ raise ValueError("No API key found")
178
+ return api_key
179
+
180
+
181
+ async def key_is_valid(api_key: str) -> bool:
182
+ """Checks if the given API key is valid"""
183
+ async with cloud_client(api_key) as client:
184
+ response = await client.request("GET", "/me/")
185
+ return response.status_code == 200
186
+
187
+
188
+ def login_interactively() -> str | None:
189
+ """Logs the user into Prefect Cloud interactively"""
190
+ result_queue: Queue[str | None] = Queue()
191
+ with login_server(result_queue) as uri:
192
+ login_url = f"{CLOUD_UI_URL}/auth/client?callback={quote(uri)}&g=true"
193
+
194
+ threading.Thread(
195
+ target=webbrowser.open_new_tab, args=(login_url,), daemon=True
196
+ ).start()
197
+
198
+ return result_queue.get()
199
+
200
+
201
+ @contextmanager
202
+ def login_server(result_queue: Queue[str | None]) -> Generator[str, None, None]:
203
+ """Runs a local server to handle the callback from Prefect Cloud"""
204
+
205
+ class LoginHandler(BaseHTTPRequestHandler):
206
+ def log_message(self, format: str, *args: Any) -> None:
207
+ pass
208
+
209
+ def add_cors_headers(self) -> None:
210
+ self.send_header("Access-Control-Allow-Origin", "*")
211
+ self.send_header("Access-Control-Allow-Headers", "*")
212
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
213
+
214
+ def do_OPTIONS(self) -> None:
215
+ self.send_response(200)
216
+ self.add_cors_headers()
217
+ self.end_headers()
218
+
219
+ def do_GET(self) -> None:
220
+ query_params = parse_qs(urlparse(self.path).query)
221
+ api_key = query_params.get("key", [""])[0] or None
222
+
223
+ self.send_response(200)
224
+ self.send_header("Content-type", "text/html")
225
+ self.end_headers()
226
+ self.wfile.write(b"""
227
+ <script>
228
+ if (window.opener) {
229
+ window.opener.postMessage('auth-complete', '*')
230
+ }
231
+ </script>
232
+ <p>You can close this window.</p>
233
+ """)
234
+ result_queue.put(api_key or None)
235
+
236
+ def do_POST(self):
237
+ content_length = int(self.headers.get("Content-Length", 0))
238
+ body = self.rfile.read(content_length)
239
+ data = json.loads(body)
240
+ api_key = data.get("api_key")
241
+
242
+ self.send_response(200)
243
+ self.add_cors_headers()
244
+ self.end_headers()
245
+ self.wfile.write(b"{}")
246
+ result_queue.put(api_key or None)
247
+
248
+ with socket.socket() as sock:
249
+ sock.bind(("", 0))
250
+ port = sock.getsockname()[1]
251
+
252
+ server = HTTPServer(("localhost", port), LoginHandler)
253
+ threading.Thread(target=server.serve_forever, daemon=True).start()
254
+
255
+ try:
256
+ yield f"http://localhost:{port}"
257
+ finally:
258
+ server.server_close()
259
+
260
+
261
+ async def me(api_key: str) -> Me:
262
+ """Gets the current user's information"""
263
+ async with cloud_client(api_key) as client:
264
+ response = await client.request("GET", "/me/")
265
+ response.raise_for_status()
266
+
267
+ return Me.model_validate_json(response.text)
268
+
269
+
270
+ async def get_accounts(api_key: str) -> Sequence[Account]:
271
+ """Gets the list of accounts for the current user"""
272
+ async with cloud_client(api_key) as client:
273
+ response = await client.request("GET", "/me/accounts")
274
+ response.raise_for_status()
275
+
276
+ return TypeAdapter(list[Account]).validate_json(response.text)
277
+
278
+
279
+ async def get_workspaces(api_key: str) -> Sequence[Workspace]:
280
+ """Gets the list of workspaces for the current user"""
281
+ async with cloud_client(api_key) as client:
282
+ response = await client.request("GET", "/me/workspaces")
283
+ response.raise_for_status()
284
+
285
+ workspaces = TypeAdapter(list[Workspace]).validate_json(response.text)
286
+ workspaces.sort(key=lambda w: w.full_handle)
287
+
288
+ return workspaces
289
+
290
+
291
+ async def lookup_workspace(api_key: str, workspace_id_or_slug: str) -> Workspace | None:
292
+ """Looks up a workspace by ID or slug"""
293
+ workspace_id: UUID | None = None
294
+ try:
295
+ workspace_id = UUID(workspace_id_or_slug)
296
+ except ValueError:
297
+ pass
298
+
299
+ for workspace in await get_workspaces(api_key):
300
+ if workspace_id and workspace.workspace_id == workspace_id:
301
+ return workspace
302
+ if workspace.full_handle == workspace_id_or_slug:
303
+ return workspace
304
+ if workspace.workspace_handle == workspace_id_or_slug:
305
+ return workspace
306
+
307
+ return None
308
+
309
+
310
+ async def prompt_for_workspace(api_key: str) -> Workspace | None:
311
+ """Prompts the user to select a workspace from the list of available workspaces"""
312
+ workspaces = await get_workspaces(api_key)
313
+
314
+ if not workspaces:
315
+ return None
316
+
317
+ if len(workspaces) == 1:
318
+ return workspaces[0]
319
+
320
+ selected = prompt_select_from_list(
321
+ "Select a workspace",
322
+ sorted([workspace.full_handle for workspace in workspaces]),
323
+ )
324
+ return next(
325
+ workspace for workspace in workspaces if workspace.full_handle == selected
326
+ )
327
+
328
+
329
+ def get_from_env_or_profile(key: str) -> str | None:
330
+ """Gets value from the environment or the current cloud profile"""
331
+ profile = get_cloud_profile() or {}
332
+
333
+ if from_env := os.environ.get(key):
334
+ return from_env
335
+ elif from_profile := profile.get(key):
336
+ return from_profile
337
+ else:
338
+ return None
339
+
340
+
341
+ def get_api_key() -> str | None:
342
+ return get_from_env_or_profile("PREFECT_API_KEY")
343
+
344
+
345
+ def get_api_url() -> str | None:
346
+ return get_from_env_or_profile("PREFECT_API_URL")
347
+
348
+
349
+ def load_profiles() -> dict[str, Any]:
350
+ """Loads the profiles from the profiles file"""
351
+ profile_path = PREFECT_HOME / "profiles.toml"
352
+ if profile_path.exists():
353
+ profiles = toml.load(profile_path)
354
+ else:
355
+ profiles = {}
356
+
357
+ if "profiles" not in profiles:
358
+ profiles["profiles"] = {}
359
+
360
+ return profiles
361
+
362
+
363
+ def cloud_profile_name() -> str:
364
+ """Returns the name of the current cloud profile"""
365
+ if CLOUD_API_URL == "https://api.stg.prefect.dev/api":
366
+ return "prefect-cloud-stg"
367
+ elif CLOUD_API_URL == "https://api.prefect.dev/api":
368
+ return "prefect-cloud-dev"
369
+ elif CLOUD_API_URL.startswith("http://localhost"):
370
+ return "prefect-cloud-lcl"
371
+ else:
372
+ return "prefect-cloud"
373
+
374
+
375
+ def get_cloud_profile() -> dict[str, str] | None:
376
+ """Returns the current cloud profile"""
377
+ profile_path = PREFECT_HOME / "profiles.toml"
378
+ if not profile_path.exists():
379
+ return None
380
+
381
+ profiles = toml.load(profile_path)
382
+ return profiles.get("profiles", {}).get(cloud_profile_name())
383
+
384
+
385
+ def set_cloud_profile(api_key: str, workspace: Workspace) -> None:
386
+ """Writes the current cloud profile"""
387
+ profile_name = cloud_profile_name()
388
+ profile_path = PREFECT_HOME / "profiles.toml"
389
+
390
+ profiles = load_profiles()
391
+
392
+ profiles["active"] = profile_name
393
+ if profile_name not in profiles["profiles"]:
394
+ profiles["profiles"][profile_name] = {}
395
+
396
+ profiles["profiles"][profile_name].update(
397
+ {
398
+ "PREFECT_API_KEY": api_key,
399
+ "PREFECT_API_URL": workspace.api_url,
400
+ }
401
+ )
402
+
403
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
404
+ profile_path.write_text(toml.dumps(profiles))
405
+
406
+
407
+ def remove_cloud_profile() -> None:
408
+ """Removes the current cloud profile"""
409
+ profile_path = PREFECT_HOME / "profiles.toml"
410
+ if not profile_path.exists():
411
+ return
412
+
413
+ profiles = load_profiles()
414
+ profiles["profiles"].pop(cloud_profile_name(), None)
415
+ try:
416
+ profiles["active"] = list(profiles["profiles"].keys())[0]
417
+ profile_path.write_text(toml.dumps(profiles))
418
+ except IndexError:
419
+ profile_path.unlink()
File without changes
@@ -0,0 +1,54 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from prefect_cloud.auth import get_cloud_urls_without_login, sync_cloud_client
6
+
7
+ COMPLETION_CACHE = Path.home() / ".prefect" / "prefect-cloud-completions.json"
8
+ CACHE_TTL = 86400
9
+
10
+
11
+ def clear_cache():
12
+ if COMPLETION_CACHE.exists():
13
+ COMPLETION_CACHE.unlink()
14
+
15
+
16
+ def complete_deployment(incomplete: str) -> list[str]:
17
+ _, api_url, api_key = get_cloud_urls_without_login()
18
+ if not api_url or not api_key:
19
+ return []
20
+
21
+ deployment_names = None
22
+ if (
23
+ COMPLETION_CACHE.exists()
24
+ and time.time() - COMPLETION_CACHE.stat().st_mtime < CACHE_TTL
25
+ ):
26
+ try:
27
+ with open(COMPLETION_CACHE) as f:
28
+ cache = json.load(f)
29
+ deployment_names = cache["deployment_names"]
30
+ except (IOError, json.JSONDecodeError, KeyError):
31
+ deployment_names = []
32
+
33
+ if deployment_names is None:
34
+ with sync_cloud_client(api_key) as client:
35
+ response = client.post(f"{api_url}/deployments/filter")
36
+ response.raise_for_status()
37
+ deployments = response.json()
38
+
39
+ response = client.post(f"{api_url}/flows/filter")
40
+ response.raise_for_status()
41
+ flows = response.json()
42
+
43
+ flow_names = {flow["id"]: flow["name"] for flow in flows}
44
+
45
+ deployment_names = [
46
+ f"{flow_names[deployment['flow_id']]}/{deployment['name']}"
47
+ for deployment in deployments
48
+ ]
49
+
50
+ COMPLETION_CACHE.parent.mkdir(parents=True, exist_ok=True)
51
+ with open(COMPLETION_CACHE, "w") as f:
52
+ json.dump({"deployment_names": deployment_names}, f)
53
+
54
+ return [name for name in deployment_names if name.startswith(incomplete)]