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