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.
Files changed (63) hide show
  1. framework/__init__.py +250 -0
  2. framework/__main__.py +89 -0
  3. framework/_assets/hooks/pre-commit +59 -0
  4. framework/_assets/init.sql +63 -0
  5. framework/_assets/schema/layer.v1.json +605 -0
  6. framework/_assets/schema/layers.v1.json +791 -0
  7. framework/_assets/schema/spec.v1.json +604 -0
  8. framework/_log.py +29 -0
  9. framework/_paths.py +45 -0
  10. framework/_secretservice.py +69 -0
  11. framework/_time.py +11 -0
  12. framework/_wincred.py +105 -0
  13. framework/_yaml.py +90 -0
  14. framework/ai.py +204 -0
  15. framework/bootstrap.py +206 -0
  16. framework/cache.py +335 -0
  17. framework/catalog.py +193 -0
  18. framework/cloud.py +899 -0
  19. framework/config_home.py +194 -0
  20. framework/connections.py +442 -0
  21. framework/console.py +41 -0
  22. framework/create.py +579 -0
  23. framework/data.py +519 -0
  24. framework/db.py +222 -0
  25. framework/dotenv.py +79 -0
  26. framework/errors.py +39 -0
  27. framework/global_config.py +238 -0
  28. framework/hooks.py +364 -0
  29. framework/integrity.py +22 -0
  30. framework/notebooks.py +384 -0
  31. framework/outputs.py +324 -0
  32. framework/packages.py +276 -0
  33. framework/project.py +52 -0
  34. framework/py.typed +0 -0
  35. framework/quarto.py +163 -0
  36. framework/scaffold.py +269 -0
  37. framework/scratch.py +108 -0
  38. framework/security.py +646 -0
  39. framework/settings.py +439 -0
  40. framework/spec/__init__.py +0 -0
  41. framework/spec/check.py +211 -0
  42. framework/spec/merge.py +195 -0
  43. framework/spec/resolve.py +254 -0
  44. framework/storage.py +516 -0
  45. framework/templates/defaults.yml +144 -0
  46. framework/templates/gitignore/course +120 -0
  47. framework/templates/gitignore/presentation +114 -0
  48. framework/templates/gitignore/project +108 -0
  49. framework/templates/gitignore/sensitive +139 -0
  50. framework/templates/skills/framework-data/SKILL.md +47 -0
  51. framework/templates/skills/framework-outputs/SKILL.md +68 -0
  52. framework/templates/skills/framework-packages/SKILL.md +33 -0
  53. framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
  54. framework/templates/skills/framework-workflow/SKILL.md +58 -0
  55. framework/templates/stubs/notebook-default.ipynb +34 -0
  56. framework/templates/stubs/notebook-default.qmd +43 -0
  57. framework/templates/stubs/notebook-minimal.qmd +15 -0
  58. framework/templates/stubs/notebook-revealjs.qmd +34 -0
  59. framework/templates/stubs/script-default.py +18 -0
  60. framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
  61. framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
  62. framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
  63. 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