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.
- prefect_cloud/__init__.py +0 -0
- prefect_cloud/auth.py +387 -0
- prefect_cloud/cli.py +475 -0
- prefect_cloud/client.py +833 -0
- prefect_cloud/completions.py +54 -0
- prefect_cloud/dependencies.py +80 -0
- prefect_cloud/deployments.py +87 -0
- prefect_cloud/github.py +113 -0
- prefect_cloud/pull_steps/__init__.py +0 -0
- prefect_cloud/pull_steps/flowify.py +78 -0
- prefect_cloud/schemas/actions.py +86 -0
- prefect_cloud/schemas/objects.py +160 -0
- prefect_cloud/schemas/responses.py +14 -0
- prefect_cloud/settings.py +18 -0
- prefect_cloud/types.py +57 -0
- prefect_cloud/utilities/__init__.py +0 -0
- prefect_cloud/utilities/callables.py +671 -0
- prefect_cloud/utilities/exception.py +41 -0
- prefect_cloud/utilities/flows.py +71 -0
- prefect_cloud/utilities/generics.py +63 -0
- prefect_cloud/utilities/pendulum.py +78 -0
- prefect_cloud/utilities/tui.py +80 -0
- prefect_cloud-0.1.0a0.dist-info/METADATA +68 -0
- prefect_cloud-0.1.0a0.dist-info/RECORD +26 -0
- prefect_cloud-0.1.0a0.dist-info/WHEEL +4 -0
- prefect_cloud-0.1.0a0.dist-info/entry_points.txt +2 -0
|
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()
|