framework-py 0.1.0.dev0__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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
framework/cloud.py
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
"""Framework Cloud (framework.pub, or a self-hosted server). Everything here is opt-in.
|
|
2
|
+
|
|
3
|
+
Nothing contacts a server unless you log in or the project was set up from
|
|
4
|
+
the cloud. Data files and code are never uploaded: the cloud receives your
|
|
5
|
+
settings document, project names, file digests for the integrity ledger,
|
|
6
|
+
and documents you publish.
|
|
7
|
+
|
|
8
|
+
The server is resolved per call, as in R:
|
|
9
|
+
|
|
10
|
+
- User calls (login, settings, sync, ``new(key=...)``): the ``url``
|
|
11
|
+
argument, then ``FW_CLOUD_URL``, then the server saved by
|
|
12
|
+
``cloud_login()``, then https://framework.pub.
|
|
13
|
+
- Project calls (publishing, the ledger): the ``url`` argument, then
|
|
14
|
+
``FW_CLOUD_URL`` in the project's ``.env``, then the environment variable,
|
|
15
|
+
then https://framework.pub. A project token is only valid on the server
|
|
16
|
+
that issued it, so these calls never use the login default.
|
|
17
|
+
|
|
18
|
+
Plain ``http`` is accepted only for localhost.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import platform
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
import zipfile
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
import pandas as pd
|
|
41
|
+
|
|
42
|
+
from framework import _secretservice, _wincred, _yaml, db, dotenv
|
|
43
|
+
from framework._log import inform, show_messages
|
|
44
|
+
from framework.config_home import config_dir, ensure_config_dir, r_config_dir
|
|
45
|
+
from framework.errors import FrameworkError, FrameworkWarning
|
|
46
|
+
from framework.integrity import file_hash
|
|
47
|
+
from framework.project import find_settings_file
|
|
48
|
+
|
|
49
|
+
DEFAULT_URL = "https://framework.pub"
|
|
50
|
+
LEGACY_SERVICE = "framework.pub"
|
|
51
|
+
_LOCAL_HOSTS = ("localhost", "127.0.0.1", "[::1]")
|
|
52
|
+
_TIMEOUT = 30.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CloudError(FrameworkError):
|
|
56
|
+
"""A Framework Cloud request failed."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version() -> str:
|
|
60
|
+
from framework import __version__
|
|
61
|
+
|
|
62
|
+
return __version__
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Servers --------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _authority(url: str) -> str:
|
|
69
|
+
authority = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", "", url)
|
|
70
|
+
authority = re.sub(r"[/?#].*$", "", authority)
|
|
71
|
+
return re.sub(r"^.*@", "", authority).lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def hostname(url: str) -> str:
|
|
75
|
+
"""The host without scheme, port, path, or user info."""
|
|
76
|
+
authority = _authority(url)
|
|
77
|
+
if authority.startswith("["):
|
|
78
|
+
return authority.split("]")[0] + "]"
|
|
79
|
+
return authority.split(":")[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def host(url: str) -> str:
|
|
83
|
+
"""Host and port as people read them (``localhost:8080``). Keys the stored token."""
|
|
84
|
+
return _authority(url)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_url(url: str) -> str:
|
|
88
|
+
"""Tidy a server URL and refuse plain http except for localhost."""
|
|
89
|
+
url = url.strip().rstrip("/")
|
|
90
|
+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
|
|
91
|
+
url = f"https://{url}"
|
|
92
|
+
scheme = url.split("://", 1)[0].lower()
|
|
93
|
+
name = hostname(url)
|
|
94
|
+
if not name:
|
|
95
|
+
raise CloudError(f"Not a server URL: {url}")
|
|
96
|
+
if scheme == "http":
|
|
97
|
+
if name not in _LOCAL_HOSTS:
|
|
98
|
+
raise CloudError(
|
|
99
|
+
f"Refusing plain http for {name}. Use https, or http only for localhost "
|
|
100
|
+
"and 127.0.0.1 (single-user mode)."
|
|
101
|
+
)
|
|
102
|
+
elif scheme != "https":
|
|
103
|
+
raise CloudError(f"Server URLs must start with https:// (got {scheme}://).")
|
|
104
|
+
return url
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_default(url: str) -> bool:
|
|
108
|
+
return host(url) == host(DEFAULT_URL)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _saved_url() -> str | None:
|
|
112
|
+
for folder in (config_dir(), r_config_dir()):
|
|
113
|
+
path = folder / "cloud.yml"
|
|
114
|
+
if path.is_file():
|
|
115
|
+
data = _yaml.read(path)
|
|
116
|
+
server = data.get("server") if isinstance(data, dict) else None
|
|
117
|
+
if isinstance(server, str) and server:
|
|
118
|
+
return server
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _project_dir(project_dir: str | Path | None) -> Path | None:
|
|
123
|
+
if project_dir is not None:
|
|
124
|
+
return Path(project_dir)
|
|
125
|
+
found = find_settings_file()
|
|
126
|
+
return found.parent if found else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _project_env(key: str, project_dir: str | Path | None = None) -> str | None:
|
|
130
|
+
root = _project_dir(project_dir)
|
|
131
|
+
return dotenv.value(root / ".env", key) if root else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Server:
|
|
136
|
+
url: str
|
|
137
|
+
source: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cloud_server(
|
|
141
|
+
url: str | None = None, scope: str = "user", project_dir: str | Path | None = None
|
|
142
|
+
) -> Server:
|
|
143
|
+
"""Which server a call uses, and where that setting came from."""
|
|
144
|
+
candidates: list[tuple[str | None, str]] = [(url, "the url argument")]
|
|
145
|
+
if scope == "project":
|
|
146
|
+
candidates.append((_project_env("FW_CLOUD_URL", project_dir), "the project's .env"))
|
|
147
|
+
candidates.append((os.environ.get("FW_CLOUD_URL"), "the FW_CLOUD_URL environment variable"))
|
|
148
|
+
if scope == "user":
|
|
149
|
+
candidates.append((_saved_url(), "your cloud_login() default"))
|
|
150
|
+
candidates.append((DEFAULT_URL, "the built-in default"))
|
|
151
|
+
value, source = next((v, s) for v, s in candidates if v)
|
|
152
|
+
return Server(normalize_url(str(value)), source)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Tokens ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def keyring_service(url: str) -> str:
|
|
159
|
+
return f"framework-cloud:{host(url)}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def token_file_name(url: str) -> str:
|
|
163
|
+
if is_default(url):
|
|
164
|
+
return "cloud-token"
|
|
165
|
+
return "cloud-token-" + re.sub(r"[^a-z0-9.-]", "_", host(url))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _legacy_token_file() -> Path:
|
|
169
|
+
return Path.home() / ".secrets" / "framework" / "token"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read_token_file(path: Path) -> str | None:
|
|
173
|
+
if not path.is_file():
|
|
174
|
+
return None
|
|
175
|
+
first = path.read_text(encoding="utf-8").splitlines()
|
|
176
|
+
token = first[0].strip() if first else ""
|
|
177
|
+
return token or None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_token_file(path: Path, token: str) -> None:
|
|
181
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
path.write_text(f"{token}\n", encoding="utf-8")
|
|
183
|
+
path.chmod(0o600)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Windows and Linux entries are read and written in R's format, so R, fw,
|
|
187
|
+
# and Python share one entry per server. macOS Keychain entries already match.
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _keyring_get(service: str) -> str | None:
|
|
191
|
+
if sys.platform == "win32":
|
|
192
|
+
return _wincred.get(service, "token")
|
|
193
|
+
if sys.platform == "linux":
|
|
194
|
+
try:
|
|
195
|
+
return _secretservice.get(service, "token")
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
try:
|
|
199
|
+
import keyring
|
|
200
|
+
|
|
201
|
+
value = keyring.get_password(service, "token")
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
return str(value) if value else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _keyring_set(service: str, token: str) -> bool:
|
|
208
|
+
try:
|
|
209
|
+
if sys.platform == "win32":
|
|
210
|
+
_wincred.set(service, "token", token)
|
|
211
|
+
elif sys.platform == "linux":
|
|
212
|
+
_secretservice.set(service, "token", token)
|
|
213
|
+
else:
|
|
214
|
+
import keyring
|
|
215
|
+
|
|
216
|
+
keyring.set_password(service, "token", token)
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _keyring_delete(service: str) -> bool:
|
|
223
|
+
try:
|
|
224
|
+
if sys.platform == "win32":
|
|
225
|
+
return _wincred.delete(service, "token")
|
|
226
|
+
if sys.platform == "linux":
|
|
227
|
+
return _secretservice.delete(service, "token")
|
|
228
|
+
import keyring
|
|
229
|
+
|
|
230
|
+
keyring.delete_password(service, "token")
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cloud_token(url: str) -> str | None:
|
|
237
|
+
"""The stored token for a server.
|
|
238
|
+
|
|
239
|
+
``FW_CLOUD_TOKEN``, then ``FW_TOKEN_FILE`` (authoritative when set), then
|
|
240
|
+
the system keyring, then the token file in Python's config folder, then
|
|
241
|
+
the one in R's. For framework.pub, the older keyring service and
|
|
242
|
+
``~/.secrets/framework/token`` are read last.
|
|
243
|
+
"""
|
|
244
|
+
if os.environ.get("FW_CLOUD_TOKEN"):
|
|
245
|
+
return os.environ["FW_CLOUD_TOKEN"]
|
|
246
|
+
if os.environ.get("FW_TOKEN_FILE"):
|
|
247
|
+
return _read_token_file(Path(os.environ["FW_TOKEN_FILE"]).expanduser())
|
|
248
|
+
token = _keyring_get(keyring_service(url))
|
|
249
|
+
if token is None and is_default(url):
|
|
250
|
+
token = _keyring_get(LEGACY_SERVICE)
|
|
251
|
+
for folder in (config_dir(), r_config_dir()):
|
|
252
|
+
token = token or _read_token_file(folder / token_file_name(url))
|
|
253
|
+
if token is None and is_default(url):
|
|
254
|
+
token = _read_token_file(_legacy_token_file())
|
|
255
|
+
return token
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _require_token(url: str) -> str:
|
|
259
|
+
token = cloud_token(url)
|
|
260
|
+
if token is None:
|
|
261
|
+
login = (
|
|
262
|
+
'fw.cloud_login("fw_...")'
|
|
263
|
+
if is_default(url)
|
|
264
|
+
else f'fw.cloud_login("fw_...", url="{url}")'
|
|
265
|
+
)
|
|
266
|
+
raise CloudError(
|
|
267
|
+
f"No cloud token found for {host(url)}. Create one at {url}/tokens and run:\n {login}"
|
|
268
|
+
)
|
|
269
|
+
return token
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def project_token(project_dir: str | Path | None = None) -> str | None:
|
|
273
|
+
"""The project's cloud key: ``FW_PROJECT_TOKEN``, then the project's ``.env``."""
|
|
274
|
+
return os.environ.get("FW_PROJECT_TOKEN") or _project_env("FW_PROJECT_TOKEN", project_dir)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _require_project_token() -> str:
|
|
278
|
+
token = project_token()
|
|
279
|
+
if token is None:
|
|
280
|
+
raise CloudError(
|
|
281
|
+
"No project token found (FW_PROJECT_TOKEN / .env). "
|
|
282
|
+
"Create the project with framework.new() while logged in."
|
|
283
|
+
)
|
|
284
|
+
return token
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Requests ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Tests replace the transport to answer requests without a network.
|
|
291
|
+
_transport: httpx.BaseTransport | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _client() -> httpx.Client:
|
|
295
|
+
return httpx.Client(
|
|
296
|
+
transport=_transport,
|
|
297
|
+
timeout=_TIMEOUT,
|
|
298
|
+
verify=not os.environ.get("FW_CLOUD_INSECURE"),
|
|
299
|
+
headers={"Accept": "application/json", "User-Agent": f"framework-py/{_version()}"},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _error(response: httpx.Response, what: str) -> CloudError:
|
|
304
|
+
try:
|
|
305
|
+
message = response.json().get("message")
|
|
306
|
+
except Exception:
|
|
307
|
+
message = None
|
|
308
|
+
return CloudError(message or f"{what} failed (HTTP {response.status_code})")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def api(
|
|
312
|
+
path: str,
|
|
313
|
+
token: str,
|
|
314
|
+
url: str,
|
|
315
|
+
method: str = "GET",
|
|
316
|
+
body: Mapping[str, Any] | None = None,
|
|
317
|
+
) -> Any:
|
|
318
|
+
"""Call the cloud API and return the decoded JSON."""
|
|
319
|
+
try:
|
|
320
|
+
with _client() as client:
|
|
321
|
+
response = client.request(
|
|
322
|
+
method,
|
|
323
|
+
f"{url}{path}",
|
|
324
|
+
json=body,
|
|
325
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
326
|
+
)
|
|
327
|
+
except httpx.HTTPError as error:
|
|
328
|
+
raise CloudError(f"Could not reach {host(url)}: {error}") from error
|
|
329
|
+
if response.is_error:
|
|
330
|
+
raise _error(response, "Cloud request")
|
|
331
|
+
return response.json()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# Logging in ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def consent_notice(url: str) -> str:
|
|
338
|
+
return (
|
|
339
|
+
f"Connecting this machine to {url} enables cloud features\n"
|
|
340
|
+
"that send data to that service when you use them:\n"
|
|
341
|
+
" - settings sync: your Framework settings document (cloud_sync())\n"
|
|
342
|
+
" - project registration: project name, type, and language (new())\n"
|
|
343
|
+
" - integrity ledger: file digests, sizes, and this machine's hostname\n"
|
|
344
|
+
" (data_save(); opt out any time with FW_LEDGER=off)\n"
|
|
345
|
+
" - publishing: documents you explicitly publish()\n"
|
|
346
|
+
"Your data files themselves are never uploaded unless you publish them.\n"
|
|
347
|
+
f"Details: {url}/privacy\n"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cloud_login(
|
|
352
|
+
token: str, url: str | None = None, verify: bool = True, consent: bool | None = None
|
|
353
|
+
) -> str:
|
|
354
|
+
"""Connect this machine to a Framework Cloud server.
|
|
355
|
+
|
|
356
|
+
The token is stored per server in the system keyring (the same entry R and
|
|
357
|
+
``fw`` use), or in a file readable only by you when no keyring is
|
|
358
|
+
available. The server becomes your default.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
token: A personal token from the server's token page.
|
|
362
|
+
url: The server. Default: ``FW_CLOUD_URL``, then your saved default,
|
|
363
|
+
then https://framework.pub.
|
|
364
|
+
verify: Check the token with the server first.
|
|
365
|
+
consent: ``True`` to confirm without a prompt (scripts, CI).
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Where the token was stored: ``"keyring"`` or a file path.
|
|
369
|
+
"""
|
|
370
|
+
show_messages()
|
|
371
|
+
if len(token) < 10:
|
|
372
|
+
raise CloudError("That does not look like a Framework Cloud token.")
|
|
373
|
+
server = cloud_server(url).url
|
|
374
|
+
name = host(server)
|
|
375
|
+
if consent is not True:
|
|
376
|
+
print(consent_notice(server), file=sys.stderr)
|
|
377
|
+
if not sys.stdin.isatty():
|
|
378
|
+
raise CloudError(
|
|
379
|
+
"cloud_login() needs confirmation. In a non-interactive session, call "
|
|
380
|
+
"cloud_login(token, consent=True) after reviewing what connecting shares "
|
|
381
|
+
f"(printed above; {server}/privacy)."
|
|
382
|
+
)
|
|
383
|
+
answer = input(f"Connect this machine to {name}? [y/N] ").strip().lower()
|
|
384
|
+
if answer not in ("y", "yes"):
|
|
385
|
+
raise CloudError("Cancelled - this machine was not connected.")
|
|
386
|
+
|
|
387
|
+
if verify:
|
|
388
|
+
me = api("/api/v1/me", token, server)
|
|
389
|
+
inform(f"[ok] Token verified on {name}: {me.get('email') or me.get('project') or 'ok'}")
|
|
390
|
+
|
|
391
|
+
override = os.environ.get("FW_TOKEN_FILE")
|
|
392
|
+
if override:
|
|
393
|
+
path = Path(override).expanduser()
|
|
394
|
+
_write_token_file(path, token)
|
|
395
|
+
stored = str(path)
|
|
396
|
+
inform(f"[ok] Token saved to {path} (FW_TOKEN_FILE)")
|
|
397
|
+
elif _keyring_set(keyring_service(server), token):
|
|
398
|
+
stored = "keyring"
|
|
399
|
+
inform(f"[ok] Token stored in your system keyring (service: {keyring_service(server)})")
|
|
400
|
+
else:
|
|
401
|
+
path = ensure_config_dir() / token_file_name(server)
|
|
402
|
+
_write_token_file(path, token)
|
|
403
|
+
stored = str(path)
|
|
404
|
+
inform(f"[ok] Token saved to {path}")
|
|
405
|
+
|
|
406
|
+
_yaml.write(ensure_config_dir() / "cloud.yml", {"server": server})
|
|
407
|
+
inform(f"[ok] Default server: {server}")
|
|
408
|
+
legacy = _legacy_token_file()
|
|
409
|
+
if not override and is_default(server) and legacy.exists():
|
|
410
|
+
legacy.unlink()
|
|
411
|
+
inform(f"[ok] Removed legacy token file {legacy}")
|
|
412
|
+
return stored
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cloud_logout(url: str | None = None) -> list[str]:
|
|
416
|
+
"""Remove this machine's stored token for a server. Returns what was removed."""
|
|
417
|
+
show_messages()
|
|
418
|
+
server = cloud_server(url).url
|
|
419
|
+
removed = []
|
|
420
|
+
services = [keyring_service(server)] + ([LEGACY_SERVICE] if is_default(server) else [])
|
|
421
|
+
for service in services:
|
|
422
|
+
if _keyring_delete(service):
|
|
423
|
+
removed.append(f"keyring ({service})")
|
|
424
|
+
paths = [config_dir() / token_file_name(server)]
|
|
425
|
+
if is_default(server):
|
|
426
|
+
paths.append(_legacy_token_file())
|
|
427
|
+
for path in paths:
|
|
428
|
+
if path.exists():
|
|
429
|
+
path.unlink()
|
|
430
|
+
removed.append(str(path))
|
|
431
|
+
if removed:
|
|
432
|
+
inform(f"[ok] Removed: {', '.join(removed)}")
|
|
433
|
+
else:
|
|
434
|
+
inform(f"No stored token found for {host(server)}.")
|
|
435
|
+
return removed
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cloud_status(url: str | None = None) -> bool:
|
|
439
|
+
"""Report the server in use and whether this machine is connected to it."""
|
|
440
|
+
show_messages()
|
|
441
|
+
server = cloud_server(url)
|
|
442
|
+
inform(f"Server: {server.url} (from {server.source})")
|
|
443
|
+
if project_token() is not None:
|
|
444
|
+
project = cloud_server(scope="project")
|
|
445
|
+
if project.url != server.url:
|
|
446
|
+
inform(f"This project publishes to {project.url} (from {project.source})")
|
|
447
|
+
token = cloud_token(server.url)
|
|
448
|
+
if token is None:
|
|
449
|
+
login = (
|
|
450
|
+
"cloud_login()" if is_default(server.url) else f'cloud_login(token, url="{server.url}")'
|
|
451
|
+
)
|
|
452
|
+
inform(f"Not connected. Run {login} to connect.")
|
|
453
|
+
return False
|
|
454
|
+
try:
|
|
455
|
+
me = api("/api/v1/me", token, server.url)
|
|
456
|
+
except CloudError as error:
|
|
457
|
+
inform(
|
|
458
|
+
f"Token found but {host(server.url)} is unreachable or the token is invalid: {error}"
|
|
459
|
+
)
|
|
460
|
+
return False
|
|
461
|
+
inform(f"Connected to {host(server.url)} as {me.get('email') or me.get('project')}")
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def cloud_settings() -> Any:
|
|
466
|
+
"""Your settings document from the cloud."""
|
|
467
|
+
server = cloud_server().url
|
|
468
|
+
return api("/api/v1/settings", _require_token(server), server)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# Settings sync ------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _sync_paths() -> tuple[Path, Path]:
|
|
475
|
+
folder = config_dir()
|
|
476
|
+
return folder / "cloud-settings.yml", folder / "sync-state.yml"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _document_hash(document: Any) -> str:
|
|
480
|
+
text = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
481
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def cloud_sync(direction: str = "auto") -> int:
|
|
485
|
+
"""Sync your settings document with the cloud. Returns the revision.
|
|
486
|
+
|
|
487
|
+
``"pull"`` takes the cloud version (backing up local edits), ``"push"``
|
|
488
|
+
sends your local mirror, and ``"auto"`` picks the side that changed and
|
|
489
|
+
stops when both did. The mirror is ``cloud-settings.yml`` in
|
|
490
|
+
``config_dir()``.
|
|
491
|
+
"""
|
|
492
|
+
show_messages()
|
|
493
|
+
if direction not in ("auto", "pull", "push"):
|
|
494
|
+
raise CloudError("direction must be auto, pull, or push")
|
|
495
|
+
server = cloud_server().url
|
|
496
|
+
token = _require_token(server)
|
|
497
|
+
document_path, state_path = _sync_paths()
|
|
498
|
+
remote = api("/api/v1/settings", token, server)
|
|
499
|
+
revision = int(remote.get("revision") or 0)
|
|
500
|
+
state = _yaml.read(state_path) if state_path.is_file() else None
|
|
501
|
+
local = _yaml.read(document_path) if document_path.is_file() else None
|
|
502
|
+
local_changed = (
|
|
503
|
+
local is not None and state is not None and _document_hash(local) != state.get("hash")
|
|
504
|
+
)
|
|
505
|
+
remote_ahead = state is None or revision > int(state.get("revision", -1))
|
|
506
|
+
|
|
507
|
+
if direction == "auto":
|
|
508
|
+
if local is None or state is None:
|
|
509
|
+
direction = "pull"
|
|
510
|
+
elif local_changed and remote_ahead:
|
|
511
|
+
raise CloudError(
|
|
512
|
+
"Both your local mirror and the cloud changed since the last sync.\n"
|
|
513
|
+
' cloud_sync("pull") to take the cloud version (your file is backed up), or\n'
|
|
514
|
+
' cloud_sync("push") to overwrite the cloud with your local mirror.'
|
|
515
|
+
)
|
|
516
|
+
elif local_changed:
|
|
517
|
+
direction = "push"
|
|
518
|
+
elif remote_ahead:
|
|
519
|
+
direction = "pull"
|
|
520
|
+
else:
|
|
521
|
+
inform(f"[ok] Already in sync (revision {revision})")
|
|
522
|
+
return revision
|
|
523
|
+
|
|
524
|
+
if direction == "pull":
|
|
525
|
+
if local is not None and local_changed:
|
|
526
|
+
backup = document_path.with_name(
|
|
527
|
+
f"{document_path.name}.backup-{datetime.now():%Y%m%d-%H%M%S}"
|
|
528
|
+
)
|
|
529
|
+
shutil.copy2(document_path, backup)
|
|
530
|
+
inform(f"Local edits backed up to {backup}")
|
|
531
|
+
ensure_config_dir()
|
|
532
|
+
_yaml.write(document_path, remote.get("document"))
|
|
533
|
+
_yaml.write(
|
|
534
|
+
state_path, {"revision": revision, "hash": _document_hash(remote.get("document"))}
|
|
535
|
+
)
|
|
536
|
+
inform(f"[ok] Pulled cloud settings (revision {revision}) -> {document_path}")
|
|
537
|
+
return revision
|
|
538
|
+
|
|
539
|
+
if local is None:
|
|
540
|
+
raise CloudError('Nothing to push: no local mirror. Run cloud_sync("pull") first.')
|
|
541
|
+
if state is None:
|
|
542
|
+
raise CloudError(
|
|
543
|
+
'No sync state. Run cloud_sync("pull") first so pushes can detect conflicts.'
|
|
544
|
+
)
|
|
545
|
+
body = {
|
|
546
|
+
"document": local,
|
|
547
|
+
"base_revision": state.get("revision"),
|
|
548
|
+
"client": {"device": platform.node(), "app": "framework-py", "version": _version()},
|
|
549
|
+
}
|
|
550
|
+
try:
|
|
551
|
+
result = api("/api/v1/settings", token, server, method="PUT", body=body)
|
|
552
|
+
except CloudError as error:
|
|
553
|
+
raise CloudError(
|
|
554
|
+
f"Push rejected: {error}\nThe cloud likely changed since your last sync. "
|
|
555
|
+
'cloud_sync("pull") first.'
|
|
556
|
+
) from error
|
|
557
|
+
new_revision = int(result.get("revision") or 0)
|
|
558
|
+
_yaml.write(state_path, {"revision": new_revision, "hash": _document_hash(local)})
|
|
559
|
+
inform(f"[ok] Pushed settings (revision {new_revision})")
|
|
560
|
+
return new_revision
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# The integrity ledger -----------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _ledger_live() -> bool:
|
|
567
|
+
mode = os.environ.get("FW_LEDGER", "")
|
|
568
|
+
if not mode:
|
|
569
|
+
try:
|
|
570
|
+
from framework.settings import settings_read
|
|
571
|
+
|
|
572
|
+
options = settings_read().get("options") or {}
|
|
573
|
+
mode = str(options.get("cloud_ledger") or "") if isinstance(options, dict) else ""
|
|
574
|
+
except FrameworkError:
|
|
575
|
+
mode = ""
|
|
576
|
+
return mode.lower() in ("live", "on", "true")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def ledger_push_quietly(name: str, digest: str, file_path: Path | None = None) -> Any:
|
|
580
|
+
"""Send one digest to the project's ledger after ``data_save()``, in live mode only.
|
|
581
|
+
|
|
582
|
+
No project token or an unreachable server means "local only", never a
|
|
583
|
+
failed save. framework.db stays the record either way.
|
|
584
|
+
"""
|
|
585
|
+
if not _ledger_live():
|
|
586
|
+
return None
|
|
587
|
+
token = project_token()
|
|
588
|
+
if token is None:
|
|
589
|
+
return None
|
|
590
|
+
url = cloud_server(scope="project").url
|
|
591
|
+
body = {
|
|
592
|
+
"name": name,
|
|
593
|
+
"algo": "sha256",
|
|
594
|
+
"hash": digest,
|
|
595
|
+
"size_bytes": file_path.stat().st_size if file_path and file_path.is_file() else None,
|
|
596
|
+
"recorded_at": datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
597
|
+
"client": {"device": platform.node(), "package_version": _version()},
|
|
598
|
+
}
|
|
599
|
+
try:
|
|
600
|
+
result = api("/api/v1/ledger", token, url, method="POST", body=body)
|
|
601
|
+
except CloudError as error:
|
|
602
|
+
inform(f"Ledger push skipped: {error}")
|
|
603
|
+
return None
|
|
604
|
+
inform(f"[ok] Ledger entry #{result.get('sequence')} recorded on {host(url)}")
|
|
605
|
+
return result
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _ledger_entries(name: str | None = None) -> list[dict[str, Any]]:
|
|
609
|
+
token = _require_project_token()
|
|
610
|
+
params = f"?{httpx.QueryParams({'name': name})}" if name else ""
|
|
611
|
+
result = api(f"/api/v1/ledger{params}", token, cloud_server(scope="project").url)
|
|
612
|
+
return list(result.get("entries") or [])
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def ledger_push() -> dict[str, int]:
|
|
616
|
+
"""Send framework.db's file digests to the project's cloud ledger.
|
|
617
|
+
|
|
618
|
+
Only names whose newest cloud entry differs are sent. Returns the counts
|
|
619
|
+
pushed and already current.
|
|
620
|
+
"""
|
|
621
|
+
show_messages()
|
|
622
|
+
token = _require_project_token()
|
|
623
|
+
with db.connect() as connection:
|
|
624
|
+
rows = connection.execute(
|
|
625
|
+
"SELECT name, hash, updated_at FROM data WHERE hash IS NOT NULL AND hash != ''"
|
|
626
|
+
).fetchall()
|
|
627
|
+
if not rows:
|
|
628
|
+
inform("Local ledger is empty - nothing to push.")
|
|
629
|
+
return {"pushed": 0, "up_to_date": 0}
|
|
630
|
+
url = cloud_server(scope="project").url
|
|
631
|
+
heads: dict[str, str] = {}
|
|
632
|
+
for entry in _ledger_entries():
|
|
633
|
+
heads.setdefault(entry["name"], entry["hash"])
|
|
634
|
+
|
|
635
|
+
pushed = up_to_date = 0
|
|
636
|
+
for row in rows:
|
|
637
|
+
if heads.get(row["name"]) == row["hash"]:
|
|
638
|
+
up_to_date += 1
|
|
639
|
+
continue
|
|
640
|
+
body: dict[str, Any] = {
|
|
641
|
+
"name": row["name"],
|
|
642
|
+
"algo": "sha256",
|
|
643
|
+
"hash": row["hash"],
|
|
644
|
+
"client": {
|
|
645
|
+
"device": platform.node(),
|
|
646
|
+
"package_version": _version(),
|
|
647
|
+
"source": "ledger_push",
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
recorded = db.parse_timestamp(row["updated_at"])
|
|
651
|
+
if recorded is not None:
|
|
652
|
+
body["recorded_at"] = recorded.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
653
|
+
try:
|
|
654
|
+
api("/api/v1/ledger", token, url, method="POST", body=body)
|
|
655
|
+
except CloudError as error:
|
|
656
|
+
inform(f" [skip] {row['name']}: {error}")
|
|
657
|
+
continue
|
|
658
|
+
inform(f" [ok] pushed {row['name']}")
|
|
659
|
+
pushed += 1
|
|
660
|
+
inform(f"[ok] Ledger push complete: {pushed} pushed, {up_to_date} already current.")
|
|
661
|
+
return {"pushed": pushed, "up_to_date": up_to_date}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def ledger_list(name: str | None = None) -> pd.DataFrame:
|
|
665
|
+
"""The project's cloud ledger, newest first."""
|
|
666
|
+
columns = ["sequence", "name", "hash", "size_bytes", "recorded_at"]
|
|
667
|
+
rows = [{key: entry.get(key) for key in columns} for entry in _ledger_entries(name)]
|
|
668
|
+
return pd.DataFrame(rows, columns=columns)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def data_verify(name: str) -> bool:
|
|
672
|
+
"""Check a catalog file against framework.db and, if connected, the cloud ledger.
|
|
673
|
+
|
|
674
|
+
Returns True when every record matches. A mismatch issues a warning.
|
|
675
|
+
"""
|
|
676
|
+
import warnings
|
|
677
|
+
|
|
678
|
+
from framework.catalog import data_info
|
|
679
|
+
|
|
680
|
+
show_messages()
|
|
681
|
+
info = data_info(name)
|
|
682
|
+
if not info.path.is_file():
|
|
683
|
+
raise FrameworkError(f"File missing: {info.path}")
|
|
684
|
+
digest = file_hash(info.path)
|
|
685
|
+
ok = True
|
|
686
|
+
inform(f"Verifying {name} ({info.path})")
|
|
687
|
+
inform(f" file sha256: {digest[:16]}...")
|
|
688
|
+
|
|
689
|
+
record = db.get_data_record(name)
|
|
690
|
+
if record is not None and record.hash:
|
|
691
|
+
if record.hash == digest:
|
|
692
|
+
inform(" [ok] matches local framework.db record")
|
|
693
|
+
else:
|
|
694
|
+
inform(f" [MISMATCH] local framework.db has {record.hash[:16]}...")
|
|
695
|
+
ok = False
|
|
696
|
+
|
|
697
|
+
if project_token() is not None:
|
|
698
|
+
try:
|
|
699
|
+
entries = _ledger_entries(name)
|
|
700
|
+
except CloudError as error:
|
|
701
|
+
inform(f" (cloud ledger unreachable: {error})")
|
|
702
|
+
entries = None
|
|
703
|
+
if entries:
|
|
704
|
+
head = entries[0]
|
|
705
|
+
if head.get("hash") == digest:
|
|
706
|
+
inform(
|
|
707
|
+
f" [ok] matches cloud ledger entry #{head.get('sequence')} "
|
|
708
|
+
f"(recorded {head.get('recorded_at') or '?'})"
|
|
709
|
+
)
|
|
710
|
+
else:
|
|
711
|
+
inform(
|
|
712
|
+
f" [MISMATCH] cloud ledger head #{head.get('sequence')} has "
|
|
713
|
+
f"{str(head.get('hash'))[:16]}..."
|
|
714
|
+
)
|
|
715
|
+
ok = False
|
|
716
|
+
elif entries is not None:
|
|
717
|
+
inform(" (no cloud ledger entries for this name yet)")
|
|
718
|
+
|
|
719
|
+
if ok:
|
|
720
|
+
inform("[ok] Verified")
|
|
721
|
+
else:
|
|
722
|
+
warnings.warn(f"Integrity mismatch for '{name}'", FrameworkWarning, stacklevel=2)
|
|
723
|
+
return ok
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# Publishing ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _zip(folder: Path, target: Path) -> int:
|
|
730
|
+
files = sorted(p for p in folder.rglob("*") if p.is_file())
|
|
731
|
+
if not files:
|
|
732
|
+
raise CloudError(f"Nothing to bundle in {folder}")
|
|
733
|
+
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as bundle:
|
|
734
|
+
for path in files:
|
|
735
|
+
bundle.write(path, path.relative_to(folder).as_posix())
|
|
736
|
+
return len(files)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def cloud_publish(
|
|
740
|
+
source: Path,
|
|
741
|
+
dest: str | None = None,
|
|
742
|
+
self_contained: bool = True,
|
|
743
|
+
slug: str | bool | None = None,
|
|
744
|
+
title: str | None = None,
|
|
745
|
+
) -> str:
|
|
746
|
+
"""Publish rendered HTML (or a .qmd to render) to the project's cloud server.
|
|
747
|
+
|
|
748
|
+
Accepts a self-contained ``.html``, an ``.html`` with a sibling
|
|
749
|
+
``<name>_files/`` folder, a render output folder, or a ``.qmd``. Each
|
|
750
|
+
publish adds a version. ``slug`` names the document and turns on its
|
|
751
|
+
readable URL (``True`` uses the derived name, ``False`` turns it off).
|
|
752
|
+
"""
|
|
753
|
+
token = _require_project_token()
|
|
754
|
+
with tempfile.TemporaryDirectory(prefix="fw_publish_") as temp:
|
|
755
|
+
stage = Path(temp)
|
|
756
|
+
html: Path | None = None
|
|
757
|
+
bundle: Path | None = None
|
|
758
|
+
entrypoint: str | None = None
|
|
759
|
+
suffix = source.suffix.lower()
|
|
760
|
+
|
|
761
|
+
if source.is_dir():
|
|
762
|
+
bundle = source
|
|
763
|
+
pages = sorted(
|
|
764
|
+
p.name for p in source.iterdir() if p.suffix.lower() in (".html", ".htm")
|
|
765
|
+
)
|
|
766
|
+
if not pages:
|
|
767
|
+
raise CloudError(f"No HTML file found at the top of {source}")
|
|
768
|
+
entrypoint = "index.html" if "index.html" in pages else pages[0]
|
|
769
|
+
elif suffix == ".qmd":
|
|
770
|
+
quarto = shutil.which("quarto")
|
|
771
|
+
if quarto is None:
|
|
772
|
+
raise CloudError("Rendering requires the quarto command on your PATH.")
|
|
773
|
+
out = stage / "render"
|
|
774
|
+
command = [
|
|
775
|
+
quarto,
|
|
776
|
+
"render",
|
|
777
|
+
str(source.resolve()),
|
|
778
|
+
"--output-dir",
|
|
779
|
+
str(out),
|
|
780
|
+
"--to",
|
|
781
|
+
"html",
|
|
782
|
+
]
|
|
783
|
+
if self_contained:
|
|
784
|
+
command.append("--embed-resources")
|
|
785
|
+
if subprocess.run(command, capture_output=True, check=False).returncode != 0:
|
|
786
|
+
raise CloudError("Quarto render failed.")
|
|
787
|
+
pages = sorted(p.name for p in out.glob("*.html"))
|
|
788
|
+
if not pages:
|
|
789
|
+
raise CloudError("Quarto render produced no HTML output.")
|
|
790
|
+
if self_contained:
|
|
791
|
+
html = out / pages[0]
|
|
792
|
+
else:
|
|
793
|
+
bundle, entrypoint = out, pages[0]
|
|
794
|
+
elif suffix in (".html", ".htm"):
|
|
795
|
+
assets = source.with_name(f"{source.stem}_files")
|
|
796
|
+
if assets.is_dir():
|
|
797
|
+
bundle = stage / "bundle"
|
|
798
|
+
bundle.mkdir()
|
|
799
|
+
shutil.copy2(source, bundle / source.name)
|
|
800
|
+
shutil.copytree(assets, bundle / assets.name)
|
|
801
|
+
entrypoint = source.name
|
|
802
|
+
else:
|
|
803
|
+
html = source
|
|
804
|
+
else:
|
|
805
|
+
raise CloudError(
|
|
806
|
+
"Framework Cloud publishing takes rendered HTML, a render folder, or a .qmd "
|
|
807
|
+
f"to render. Got: {suffix or source.name}"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
readable = None if slug is None else slug is not False
|
|
811
|
+
name = slug if isinstance(slug, str) else (dest or source.stem)
|
|
812
|
+
data: dict[str, str] = {"slug": re.sub(r"\.html?$", "", name)}
|
|
813
|
+
if readable is not None:
|
|
814
|
+
data["readable"] = "1" if readable else "0"
|
|
815
|
+
if title is not None:
|
|
816
|
+
data["title"] = title
|
|
817
|
+
if bundle is not None:
|
|
818
|
+
archive = stage / "bundle.zip"
|
|
819
|
+
count = _zip(bundle, archive)
|
|
820
|
+
inform(f"Publishing bundle ({count} files)")
|
|
821
|
+
data["entrypoint"] = str(entrypoint)
|
|
822
|
+
upload = ("bundle", archive, "application/zip")
|
|
823
|
+
else:
|
|
824
|
+
assert html is not None
|
|
825
|
+
upload = ("file", html, "text/html")
|
|
826
|
+
|
|
827
|
+
url = cloud_server(scope="project").url
|
|
828
|
+
field, path, content_type = upload
|
|
829
|
+
try:
|
|
830
|
+
with _client() as client, path.open("rb") as handle:
|
|
831
|
+
response = client.post(
|
|
832
|
+
f"{url}/api/v1/documents",
|
|
833
|
+
data=data,
|
|
834
|
+
files={field: (path.name, handle, content_type)},
|
|
835
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
836
|
+
)
|
|
837
|
+
except httpx.HTTPError as error:
|
|
838
|
+
raise CloudError(f"Could not reach {host(url)}: {error}") from error
|
|
839
|
+
if response.is_error:
|
|
840
|
+
raise _error(response, "Publish")
|
|
841
|
+
result = response.json()
|
|
842
|
+
if result.get("version") is not None:
|
|
843
|
+
inform(f"Published version {result['version']}: {result.get('url')}")
|
|
844
|
+
else:
|
|
845
|
+
inform(f"Published: {result.get('url')}")
|
|
846
|
+
if result.get("notice"):
|
|
847
|
+
inform(str(result["notice"]))
|
|
848
|
+
return str(result.get("url"))
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# Projects ---------------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def write_project_token(project_dir: Path, token: str, url: str) -> list[str]:
|
|
855
|
+
"""Add the project's key and server to its ``.env`` (never to the settings)."""
|
|
856
|
+
env = project_dir / ".env"
|
|
857
|
+
present = dotenv.read(env)
|
|
858
|
+
missing = {
|
|
859
|
+
k: v for k, v in (("FW_PROJECT_TOKEN", token), ("FW_CLOUD_URL", url)) if k not in present
|
|
860
|
+
}
|
|
861
|
+
if not missing:
|
|
862
|
+
return []
|
|
863
|
+
text = env.read_text(encoding="utf-8") if env.exists() else ""
|
|
864
|
+
if text and not text.endswith("\n"):
|
|
865
|
+
text += "\n"
|
|
866
|
+
if text:
|
|
867
|
+
text += "\n"
|
|
868
|
+
text += "# Framework Cloud project key and server (created by framework.new)\n"
|
|
869
|
+
text += "".join(f"{key}={value}\n" for key, value in missing.items())
|
|
870
|
+
env.write_text(text, encoding="utf-8")
|
|
871
|
+
inform(f" Added: {' and '.join(missing)} to .env")
|
|
872
|
+
return list(missing)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def register_project(project_dir: Path, name: str, type: str) -> Any:
|
|
876
|
+
"""Register a new local project on the cloud when logged in. Never fails creation."""
|
|
877
|
+
server = cloud_server().url
|
|
878
|
+
token = cloud_token(server)
|
|
879
|
+
if token is None:
|
|
880
|
+
return None
|
|
881
|
+
body = {"name": name, "project_type": type, "language": "python"}
|
|
882
|
+
try:
|
|
883
|
+
result = api("/api/v1/projects", token, server, method="POST", body=body)
|
|
884
|
+
except CloudError as error:
|
|
885
|
+
inform(f"Cloud project registration skipped: {error}")
|
|
886
|
+
return None
|
|
887
|
+
inform(f"[ok] Registered on {host(server)}: {result['project']['slug']}")
|
|
888
|
+
write_project_token(project_dir, result["pull_token"], server)
|
|
889
|
+
return result
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def fetch_layers(key: str, url: str | None = None) -> tuple[dict[str, Any], str]:
|
|
893
|
+
"""A project's layers file from the cloud, by its project token."""
|
|
894
|
+
server = cloud_server(url).url
|
|
895
|
+
inform(f"Fetching this project's definition from {server} (what is shared: {server}/privacy)")
|
|
896
|
+
envelope = api("/api/v2/projects/pull", key, server)
|
|
897
|
+
if not isinstance(envelope, dict):
|
|
898
|
+
raise CloudError("The server returned an unexpected project definition.")
|
|
899
|
+
return envelope, server
|