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/storage.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""S3-compatible storage buckets and publishing.
|
|
2
|
+
|
|
3
|
+
Buckets live under ``connections.storage_buckets`` in the settings::
|
|
4
|
+
|
|
5
|
+
connections:
|
|
6
|
+
default_storage_bucket: reports
|
|
7
|
+
storage_buckets:
|
|
8
|
+
reports: {bucket: my-reports, region: us-east-1, prefix: project-x}
|
|
9
|
+
|
|
10
|
+
Credentials come from the environment (``S3_ACCESS_KEY`` and
|
|
11
|
+
``S3_SECRET_KEY``, or the AWS variables), usually set in ``.env``. As in R,
|
|
12
|
+
environment variables take precedence over values in the settings.
|
|
13
|
+
|
|
14
|
+
Publishing uploads files with a public-read ACL and returns their URLs.
|
|
15
|
+
Sources inside a ``private`` folder of the project are refused unless
|
|
16
|
+
``allow_private=True`` is passed, as in R.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import gzip
|
|
22
|
+
import mimetypes
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import tempfile
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from framework._log import inform
|
|
36
|
+
from framework.errors import FrameworkError
|
|
37
|
+
from framework.settings import settings_read
|
|
38
|
+
|
|
39
|
+
_CONTENT_TYPES = {
|
|
40
|
+
".html": "text/html",
|
|
41
|
+
".htm": "text/html",
|
|
42
|
+
".css": "text/css",
|
|
43
|
+
".js": "application/javascript",
|
|
44
|
+
".json": "application/json",
|
|
45
|
+
".xml": "application/xml",
|
|
46
|
+
".csv": "text/csv",
|
|
47
|
+
".txt": "text/plain",
|
|
48
|
+
".md": "text/markdown",
|
|
49
|
+
".png": "image/png",
|
|
50
|
+
".jpg": "image/jpeg",
|
|
51
|
+
".jpeg": "image/jpeg",
|
|
52
|
+
".gif": "image/gif",
|
|
53
|
+
".svg": "image/svg+xml",
|
|
54
|
+
".pdf": "application/pdf",
|
|
55
|
+
".zip": "application/zip",
|
|
56
|
+
".gz": "application/gzip",
|
|
57
|
+
".parquet": "application/vnd.apache.parquet",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _section(config: Mapping[str, Any], key: str) -> dict[str, Any]:
|
|
62
|
+
value = config.get(key)
|
|
63
|
+
return value if isinstance(value, dict) else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bucket_configs(config: Mapping[str, Any] | None = None) -> dict[str, dict[str, Any]]:
|
|
67
|
+
"""Every storage bucket in the settings, by name.
|
|
68
|
+
|
|
69
|
+
Read from ``connections.storage_buckets``, and from a top-level
|
|
70
|
+
``storage_buckets`` section, where projects created by R before 2.0 have it.
|
|
71
|
+
"""
|
|
72
|
+
config = config if config is not None else settings_read()
|
|
73
|
+
found: dict[str, dict[str, Any]] = {}
|
|
74
|
+
for source in (
|
|
75
|
+
_section(config, "storage_buckets"),
|
|
76
|
+
_section(_section(config, "connections"), "storage_buckets"),
|
|
77
|
+
):
|
|
78
|
+
found.update(
|
|
79
|
+
{name: dict(entry) for name, entry in source.items() if isinstance(entry, dict)}
|
|
80
|
+
)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def default_bucket(config: Mapping[str, Any] | None = None) -> str | None:
|
|
85
|
+
config = config if config is not None else settings_read()
|
|
86
|
+
value = _section(config, "connections").get("default_storage_bucket") or config.get(
|
|
87
|
+
"default_storage_bucket"
|
|
88
|
+
)
|
|
89
|
+
return str(value) if value else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Bucket:
|
|
94
|
+
"""A resolved storage bucket: where uploads go and how to reach it."""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
bucket: str
|
|
98
|
+
region: str
|
|
99
|
+
prefix: str = ""
|
|
100
|
+
endpoint: str | None = None
|
|
101
|
+
use_https: bool = True
|
|
102
|
+
static_hosting: bool = False
|
|
103
|
+
access_key: str = ""
|
|
104
|
+
secret_key: str = ""
|
|
105
|
+
session_token: str | None = None
|
|
106
|
+
|
|
107
|
+
def key(self, dest: str) -> str:
|
|
108
|
+
key = f"{self.prefix}/{dest}" if self.prefix else dest
|
|
109
|
+
return re.sub(r"/+", "/", key).lstrip("/")
|
|
110
|
+
|
|
111
|
+
def url(self, key: str) -> str:
|
|
112
|
+
"""The public URL of an object."""
|
|
113
|
+
if self.endpoint:
|
|
114
|
+
protocol = "https" if self.use_https else "http"
|
|
115
|
+
return f"{protocol}://{self.endpoint}/{self.bucket}/{key}"
|
|
116
|
+
return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{key}"
|
|
117
|
+
|
|
118
|
+
def client(self) -> Any:
|
|
119
|
+
try:
|
|
120
|
+
import boto3
|
|
121
|
+
except ImportError as error:
|
|
122
|
+
raise FrameworkError(
|
|
123
|
+
"Storage buckets need the boto3 package. "
|
|
124
|
+
"Install it with: pip install framework-py[s3]"
|
|
125
|
+
) from error
|
|
126
|
+
endpoint = (
|
|
127
|
+
f"{'https' if self.use_https else 'http'}://{self.endpoint}" if self.endpoint else None
|
|
128
|
+
)
|
|
129
|
+
return boto3.client(
|
|
130
|
+
"s3",
|
|
131
|
+
aws_access_key_id=self.access_key,
|
|
132
|
+
aws_secret_access_key=self.secret_key,
|
|
133
|
+
aws_session_token=self.session_token,
|
|
134
|
+
region_name=self.region,
|
|
135
|
+
endpoint_url=endpoint,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _first(
|
|
140
|
+
config: Mapping[str, Any], keys: tuple[str, ...], env: tuple[str, ...], default: str = ""
|
|
141
|
+
) -> str:
|
|
142
|
+
for name in env:
|
|
143
|
+
if os.environ.get(name):
|
|
144
|
+
return os.environ[name]
|
|
145
|
+
for key in keys:
|
|
146
|
+
if config.get(key):
|
|
147
|
+
return str(config[key])
|
|
148
|
+
return default
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _build(name: str, entry: Mapping[str, Any]) -> Bucket:
|
|
152
|
+
access = _first(entry, ("access_key_id", "access_key"), ("S3_ACCESS_KEY", "AWS_ACCESS_KEY_ID"))
|
|
153
|
+
secret = _first(
|
|
154
|
+
entry, ("secret_access_key", "secret_key"), ("S3_SECRET_KEY", "AWS_SECRET_ACCESS_KEY")
|
|
155
|
+
)
|
|
156
|
+
if not access or not secret:
|
|
157
|
+
raise FrameworkError(
|
|
158
|
+
"S3 credentials not found. Provide them in the connection settings or set\n"
|
|
159
|
+
" S3_ACCESS_KEY and S3_SECRET_KEY (or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY)"
|
|
160
|
+
)
|
|
161
|
+
bucket = _first(entry, ("bucket",), ("S3_BUCKET",))
|
|
162
|
+
if not bucket:
|
|
163
|
+
raise FrameworkError(
|
|
164
|
+
"S3 bucket not specified. Set it in the connection settings or S3_BUCKET"
|
|
165
|
+
)
|
|
166
|
+
endpoint = _first(entry, ("endpoint",), ("S3_ENDPOINT",))
|
|
167
|
+
return Bucket(
|
|
168
|
+
name=name,
|
|
169
|
+
bucket=bucket,
|
|
170
|
+
region=_first(
|
|
171
|
+
entry, ("region",), ("S3_REGION", "AWS_REGION", "AWS_DEFAULT_REGION"), "us-east-1"
|
|
172
|
+
),
|
|
173
|
+
prefix=str(entry.get("prefix") or ""),
|
|
174
|
+
endpoint=re.sub(r"^https?://", "", endpoint).rstrip("/") or None,
|
|
175
|
+
use_https=not endpoint.startswith("http://"),
|
|
176
|
+
static_hosting=entry.get("static_hosting") is True,
|
|
177
|
+
access_key=access,
|
|
178
|
+
secret_key=secret,
|
|
179
|
+
session_token=_first(entry, ("session_token",), ("S3_SESSION_TOKEN", "AWS_SESSION_TOKEN"))
|
|
180
|
+
or None,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def resolve_bucket(connection: str | None = None) -> Bucket:
|
|
185
|
+
"""The named bucket, else the default, else one marked ``default: true``, else the first."""
|
|
186
|
+
config = settings_read()
|
|
187
|
+
buckets = bucket_configs(config)
|
|
188
|
+
if not buckets:
|
|
189
|
+
raise FrameworkError(
|
|
190
|
+
"No storage buckets configured. Add one in settings/connections.yml:\n\n"
|
|
191
|
+
"connections:\n storage_buckets:\n my_s3:\n bucket: my-bucket\n"
|
|
192
|
+
" region: us-east-1\n default_storage_bucket: my_s3"
|
|
193
|
+
)
|
|
194
|
+
if connection is not None:
|
|
195
|
+
if connection not in buckets:
|
|
196
|
+
raise FrameworkError(
|
|
197
|
+
f"Storage bucket '{connection}' not found. Available: {', '.join(buckets)}"
|
|
198
|
+
)
|
|
199
|
+
return _build(connection, buckets[connection])
|
|
200
|
+
name = default_bucket(config)
|
|
201
|
+
if name not in buckets:
|
|
202
|
+
name = next(
|
|
203
|
+
(n for n, e in buckets.items() if e.get("default") is True), next(iter(buckets))
|
|
204
|
+
)
|
|
205
|
+
return _build(name, buckets[name])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Uploading --------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _refuse_private(path: Path, allow_private: bool) -> None:
|
|
212
|
+
"""Refuse a source inside a folder named ``private`` within the project.
|
|
213
|
+
|
|
214
|
+
Folders above the project root do not count (macOS keeps temporary files
|
|
215
|
+
under ``/private``), and nothing is refused outside a project.
|
|
216
|
+
"""
|
|
217
|
+
from framework.project import find_settings_file
|
|
218
|
+
|
|
219
|
+
settings_file = find_settings_file()
|
|
220
|
+
if allow_private or settings_file is None:
|
|
221
|
+
return
|
|
222
|
+
root = settings_file.parent.resolve()
|
|
223
|
+
resolved = path.resolve()
|
|
224
|
+
if not resolved.is_relative_to(root):
|
|
225
|
+
return
|
|
226
|
+
relative = resolved.relative_to(root)
|
|
227
|
+
if "private" in relative.parts:
|
|
228
|
+
raise FrameworkError(
|
|
229
|
+
f"Refusing to publish '{relative.as_posix()}': it is inside a private folder, which "
|
|
230
|
+
"holds restricted data. Pass allow_private=True if you really mean to publish it."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _content_type(path: Path) -> str:
|
|
235
|
+
return (
|
|
236
|
+
_CONTENT_TYPES.get(path.suffix.lower())
|
|
237
|
+
or mimetypes.guess_type(path.name)[0]
|
|
238
|
+
or ("application/octet-stream")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def upload_file(path: Path, dest: str, bucket: Bucket) -> str:
|
|
243
|
+
"""Upload one file with a public-read ACL and return its URL."""
|
|
244
|
+
if not path.is_file():
|
|
245
|
+
raise FrameworkError(f"File not found: {path}")
|
|
246
|
+
key = bucket.key(dest)
|
|
247
|
+
client = bucket.client()
|
|
248
|
+
extra = {"ContentType": _content_type(path), "ACL": "public-read"}
|
|
249
|
+
try:
|
|
250
|
+
client.upload_file(str(path), bucket.bucket, key, ExtraArgs=extra)
|
|
251
|
+
except Exception as error:
|
|
252
|
+
# Buckets with ACLs turned off reject the ACL; their policy decides access.
|
|
253
|
+
if "AccessControlListNotSupported" not in str(error):
|
|
254
|
+
raise FrameworkError(f"Failed to upload to S3: {error}") from error
|
|
255
|
+
del extra["ACL"]
|
|
256
|
+
try:
|
|
257
|
+
client.upload_file(str(path), bucket.bucket, key, ExtraArgs=extra)
|
|
258
|
+
except Exception as retry_error:
|
|
259
|
+
raise FrameworkError(f"Failed to upload to S3: {retry_error}") from retry_error
|
|
260
|
+
return bucket.url(key)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _files(folder: Path, recursive: bool, pattern: str | None) -> list[Path]:
|
|
264
|
+
found = folder.rglob("*") if recursive else folder.iterdir()
|
|
265
|
+
files = sorted(p for p in found if p.is_file())
|
|
266
|
+
if pattern is not None:
|
|
267
|
+
files = [p for p in files if re.search(pattern, p.as_posix())]
|
|
268
|
+
return files
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _upload_folder(folder: Path, dest: str, bucket: Bucket, files: list[Path]) -> list[str]:
|
|
272
|
+
return [upload_file(p, f"{dest}/{p.relative_to(folder).as_posix()}", bucket) for p in files]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _has_project_token() -> bool:
|
|
276
|
+
from framework.dotenv import value
|
|
277
|
+
from framework.project import find_settings_file
|
|
278
|
+
|
|
279
|
+
if os.environ.get("FW_PROJECT_TOKEN"):
|
|
280
|
+
return True
|
|
281
|
+
settings_file = find_settings_file()
|
|
282
|
+
return bool(settings_file and value(settings_file.parent / ".env", "FW_PROJECT_TOKEN"))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _target(connection: str | None) -> str:
|
|
286
|
+
if connection is not None or bucket_configs():
|
|
287
|
+
return "s3"
|
|
288
|
+
if _has_project_token():
|
|
289
|
+
return "cloud"
|
|
290
|
+
raise FrameworkError(
|
|
291
|
+
"Nowhere to publish. Add a storage bucket in settings/connections.yml, or set up this "
|
|
292
|
+
"project from Framework Cloud.\nA storage bucket looks like this:\n"
|
|
293
|
+
"connections:\n storage_buckets:\n my_s3:\n bucket: my-bucket\n"
|
|
294
|
+
" region: us-east-1\n default_storage_bucket: my_s3"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _slug_ignored(slug: str | bool | None) -> None:
|
|
299
|
+
if slug is not None:
|
|
300
|
+
import warnings
|
|
301
|
+
|
|
302
|
+
from framework.errors import FrameworkWarning
|
|
303
|
+
|
|
304
|
+
warnings.warn(
|
|
305
|
+
"`slug` applies to Framework Cloud only; ignored for storage buckets.",
|
|
306
|
+
FrameworkWarning,
|
|
307
|
+
stacklevel=3,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def publish(
|
|
312
|
+
source: str | Path,
|
|
313
|
+
dest: str | None = None,
|
|
314
|
+
connection: str | None = None,
|
|
315
|
+
overwrite: bool = True,
|
|
316
|
+
self_contained: bool = True,
|
|
317
|
+
slug: str | bool | None = None,
|
|
318
|
+
allow_private: bool = False,
|
|
319
|
+
) -> str | list[str]:
|
|
320
|
+
"""Upload a file or folder to the project's storage bucket.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
source: A file or folder.
|
|
324
|
+
dest: Where to put it in the bucket. Default: the source's name.
|
|
325
|
+
connection: A storage bucket name. Default: the project's default bucket.
|
|
326
|
+
overwrite: Accepted for parity with R; uploads always replace.
|
|
327
|
+
self_contained: For Framework Cloud publishing.
|
|
328
|
+
slug: For Framework Cloud publishing.
|
|
329
|
+
allow_private: Publish even though the source is inside a ``private``
|
|
330
|
+
folder. Only a person who has reviewed the file should set this.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The public URL, or a list of URLs for a folder.
|
|
334
|
+
"""
|
|
335
|
+
path = Path(source)
|
|
336
|
+
if not path.exists():
|
|
337
|
+
raise FrameworkError(f"Source not found: {path}")
|
|
338
|
+
_refuse_private(path, allow_private)
|
|
339
|
+
if _target(connection) == "cloud":
|
|
340
|
+
from framework.cloud import cloud_publish
|
|
341
|
+
|
|
342
|
+
return cloud_publish(path, dest=dest, self_contained=self_contained, slug=slug)
|
|
343
|
+
_slug_ignored(slug)
|
|
344
|
+
bucket = resolve_bucket(connection)
|
|
345
|
+
dest = dest or path.name
|
|
346
|
+
if path.is_dir():
|
|
347
|
+
urls = _upload_folder(path, dest, bucket, _files(path, True, None))
|
|
348
|
+
inform(f"Published {len(urls)} files to S3")
|
|
349
|
+
return urls
|
|
350
|
+
url = upload_file(path, dest, bucket)
|
|
351
|
+
inform(f"Published: {url}")
|
|
352
|
+
return url
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def publish_notebook(
|
|
356
|
+
file: str | Path,
|
|
357
|
+
dest: str | None = None,
|
|
358
|
+
connection: str | None = None,
|
|
359
|
+
self_contained: bool = True,
|
|
360
|
+
format: str = "html",
|
|
361
|
+
slug: str | bool | None = None,
|
|
362
|
+
allow_private: bool = False,
|
|
363
|
+
) -> str:
|
|
364
|
+
"""Render a Quarto notebook with the ``quarto`` command and upload the result.
|
|
365
|
+
|
|
366
|
+
A bucket with ``static_hosting: true`` gets ``<dest>/index.html`` and a
|
|
367
|
+
clean URL ending in ``/``. Other buckets get ``<dest>.html`` as one
|
|
368
|
+
self-contained file.
|
|
369
|
+
"""
|
|
370
|
+
source = Path(file)
|
|
371
|
+
if not source.is_file() or source.suffix.lower() not in (".qmd", ".ipynb"):
|
|
372
|
+
raise FrameworkError(f"publish_notebook() needs an existing .qmd or .ipynb file: {source}")
|
|
373
|
+
_refuse_private(source, allow_private)
|
|
374
|
+
quarto = shutil.which("quarto")
|
|
375
|
+
if quarto is None:
|
|
376
|
+
raise FrameworkError(
|
|
377
|
+
"Quarto not found. Install it from https://quarto.org/docs/get-started/"
|
|
378
|
+
)
|
|
379
|
+
if _target(connection) == "cloud":
|
|
380
|
+
if format != "html":
|
|
381
|
+
raise FrameworkError(f'Framework Cloud publishes HTML only (got format="{format}").')
|
|
382
|
+
from framework.cloud import cloud_publish
|
|
383
|
+
|
|
384
|
+
return cloud_publish(source, dest=dest, self_contained=self_contained, slug=slug)
|
|
385
|
+
_slug_ignored(slug)
|
|
386
|
+
bucket = resolve_bucket(connection)
|
|
387
|
+
self_contained = self_contained or not bucket.static_hosting
|
|
388
|
+
|
|
389
|
+
with tempfile.TemporaryDirectory(prefix="publish_notebook_") as temp:
|
|
390
|
+
command = [quarto, "render", str(source.resolve()), "--output-dir", temp, "--to", format]
|
|
391
|
+
if self_contained:
|
|
392
|
+
command.append("--embed-resources")
|
|
393
|
+
inform(f"Rendering {source.name}...")
|
|
394
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
395
|
+
if result.returncode != 0:
|
|
396
|
+
raise FrameworkError(f"Quarto render failed:\n{result.stdout}{result.stderr}")
|
|
397
|
+
outputs = sorted(Path(temp).glob("*.html"))
|
|
398
|
+
if not outputs:
|
|
399
|
+
raise FrameworkError("No HTML output found after rendering")
|
|
400
|
+
base = re.sub(r"\.html$", "", (dest or source.stem).rstrip("/"))
|
|
401
|
+
|
|
402
|
+
if bucket.static_hosting:
|
|
403
|
+
if self_contained:
|
|
404
|
+
upload_file(outputs[0], f"{base}/index.html", bucket)
|
|
405
|
+
else:
|
|
406
|
+
index = Path(temp) / "index.html"
|
|
407
|
+
if outputs[0] != index:
|
|
408
|
+
outputs[0].rename(index)
|
|
409
|
+
urls = _upload_folder(Path(temp), base, bucket, _files(Path(temp), True, None))
|
|
410
|
+
inform(f"Published {len(urls)} files")
|
|
411
|
+
url = bucket.url(bucket.key(f"{base}/"))
|
|
412
|
+
else:
|
|
413
|
+
url = upload_file(outputs[0], f"{base}.html", bucket)
|
|
414
|
+
inform(f"Published: {url}")
|
|
415
|
+
return url
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def publish_data(
|
|
419
|
+
data: pd.DataFrame | str | Path,
|
|
420
|
+
dest: str,
|
|
421
|
+
format: str = "csv",
|
|
422
|
+
connection: str | None = None,
|
|
423
|
+
compress: bool = False,
|
|
424
|
+
allow_private: bool = False,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Upload a DataFrame (as CSV, Parquet, or JSON) or a data file.
|
|
427
|
+
|
|
428
|
+
With ``compress``, the file is gzipped and ``.gz`` is added to ``dest``.
|
|
429
|
+
"""
|
|
430
|
+
if format not in ("csv", "parquet", "json"):
|
|
431
|
+
raise FrameworkError("format must be csv, parquet, or json (rds is R-only)")
|
|
432
|
+
bucket = resolve_bucket(connection)
|
|
433
|
+
with tempfile.TemporaryDirectory(prefix="publish_data_") as temp:
|
|
434
|
+
if isinstance(data, pd.DataFrame):
|
|
435
|
+
source = Path(temp) / f"data.{format}"
|
|
436
|
+
if format == "csv":
|
|
437
|
+
data.to_csv(source, index=False, lineterminator="\n")
|
|
438
|
+
elif format == "parquet":
|
|
439
|
+
data.to_parquet(source, index=False)
|
|
440
|
+
else:
|
|
441
|
+
data.to_json(source, orient="records")
|
|
442
|
+
else:
|
|
443
|
+
source = Path(data)
|
|
444
|
+
if not source.is_file():
|
|
445
|
+
raise FrameworkError(f"File not found: {source}")
|
|
446
|
+
_refuse_private(source, allow_private)
|
|
447
|
+
if compress:
|
|
448
|
+
compressed = Path(temp) / f"{source.name}.gz"
|
|
449
|
+
with source.open("rb") as raw, gzip.open(compressed, "wb") as packed:
|
|
450
|
+
shutil.copyfileobj(raw, packed)
|
|
451
|
+
source = compressed
|
|
452
|
+
dest = dest if dest.endswith(".gz") else f"{dest}.gz"
|
|
453
|
+
url = upload_file(source, dest, bucket)
|
|
454
|
+
inform(f"Published: {url}")
|
|
455
|
+
return url
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def publish_dir(
|
|
459
|
+
dir: str | Path,
|
|
460
|
+
dest: str | None = None,
|
|
461
|
+
connection: str | None = None,
|
|
462
|
+
pattern: str | None = None,
|
|
463
|
+
recursive: bool = True,
|
|
464
|
+
allow_private: bool = False,
|
|
465
|
+
) -> list[str]:
|
|
466
|
+
"""Upload the files in a folder. ``pattern`` is a regular expression on the paths."""
|
|
467
|
+
folder = Path(dir)
|
|
468
|
+
if not folder.is_dir():
|
|
469
|
+
raise FrameworkError(f"Directory not found: {folder}")
|
|
470
|
+
_refuse_private(folder, allow_private)
|
|
471
|
+
bucket = resolve_bucket(connection)
|
|
472
|
+
files = _files(folder, recursive, pattern)
|
|
473
|
+
if not files:
|
|
474
|
+
inform("No files found to upload")
|
|
475
|
+
return []
|
|
476
|
+
dest = dest or folder.resolve().name
|
|
477
|
+
inform(f"Uploading {len(files)} files...")
|
|
478
|
+
urls = _upload_folder(folder, dest, bucket, files)
|
|
479
|
+
inform(f"Published {len(urls)} files to {dest}")
|
|
480
|
+
return urls
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def publish_list(
|
|
484
|
+
prefix: str | None = None, connection: str | None = None, max: int = 1000
|
|
485
|
+
) -> pd.DataFrame:
|
|
486
|
+
"""Objects in the bucket (under its prefix): key, size, last_modified, and url."""
|
|
487
|
+
bucket = resolve_bucket(connection)
|
|
488
|
+
full_prefix = bucket.key(prefix) if prefix else bucket.prefix
|
|
489
|
+
try:
|
|
490
|
+
response = bucket.client().list_objects_v2(
|
|
491
|
+
Bucket=bucket.bucket, Prefix=full_prefix, MaxKeys=max
|
|
492
|
+
)
|
|
493
|
+
except Exception as error:
|
|
494
|
+
raise FrameworkError(f"Failed to list S3 objects: {error}") from error
|
|
495
|
+
rows = [
|
|
496
|
+
{
|
|
497
|
+
"key": item["Key"],
|
|
498
|
+
"size": item.get("Size"),
|
|
499
|
+
"last_modified": str(item.get("LastModified", "")),
|
|
500
|
+
"url": bucket.url(item["Key"]),
|
|
501
|
+
}
|
|
502
|
+
for item in response.get("Contents", [])
|
|
503
|
+
]
|
|
504
|
+
return pd.DataFrame(rows, columns=["key", "size", "last_modified", "url"])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def storage_test(connection: str | None = None) -> bool:
|
|
508
|
+
"""Whether the bucket exists and the credentials can reach it."""
|
|
509
|
+
bucket = resolve_bucket(connection)
|
|
510
|
+
try:
|
|
511
|
+
bucket.client().head_bucket(Bucket=bucket.bucket)
|
|
512
|
+
except Exception as error:
|
|
513
|
+
inform(f"S3 connection failed: {error}")
|
|
514
|
+
return False
|
|
515
|
+
inform(f"S3 connection OK: {bucket.name} (bucket: {bucket.bucket})")
|
|
516
|
+
return True
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Package defaults for Python projects: layer 0 of spec_resolve().
|
|
2
|
+
# Directory layouts and render folders match the R package, so a project's
|
|
3
|
+
# data and output folders are the same whichever language created it.
|
|
4
|
+
|
|
5
|
+
author: {name: "", email: "", affiliation: ""}
|
|
6
|
+
|
|
7
|
+
packages:
|
|
8
|
+
use_renv: false
|
|
9
|
+
default_packages: {}
|
|
10
|
+
|
|
11
|
+
git:
|
|
12
|
+
use_git: true
|
|
13
|
+
hooks: {data_security: false}
|
|
14
|
+
|
|
15
|
+
ai:
|
|
16
|
+
enabled: true
|
|
17
|
+
assistants: {claude: true}
|
|
18
|
+
agents_md: stub
|
|
19
|
+
|
|
20
|
+
scaffold:
|
|
21
|
+
seed_on_scaffold: false
|
|
22
|
+
seed: "123"
|
|
23
|
+
source_all_functions: true
|
|
24
|
+
notebook_format: quarto
|
|
25
|
+
ide: vscode
|
|
26
|
+
|
|
27
|
+
quarto:
|
|
28
|
+
html:
|
|
29
|
+
format: html
|
|
30
|
+
embed_resources: true
|
|
31
|
+
theme: default
|
|
32
|
+
toc: true
|
|
33
|
+
toc_depth: 3
|
|
34
|
+
code_fold: false
|
|
35
|
+
code_tools: false
|
|
36
|
+
highlight_style: github
|
|
37
|
+
revealjs:
|
|
38
|
+
format: revealjs
|
|
39
|
+
theme: default
|
|
40
|
+
incremental: false
|
|
41
|
+
slide_number: true
|
|
42
|
+
transition: slide
|
|
43
|
+
background_transition: fade
|
|
44
|
+
controls: true
|
|
45
|
+
progress: true
|
|
46
|
+
center: true
|
|
47
|
+
highlight_style: github
|
|
48
|
+
|
|
49
|
+
connections:
|
|
50
|
+
default_database: framework
|
|
51
|
+
default_storage_bucket: null
|
|
52
|
+
databases:
|
|
53
|
+
framework: {driver: sqlite, database: framework.db}
|
|
54
|
+
storage_buckets: {}
|
|
55
|
+
|
|
56
|
+
env:
|
|
57
|
+
POSTGRES_HOST: ""
|
|
58
|
+
POSTGRES_PORT: "5432"
|
|
59
|
+
POSTGRES_DB: ""
|
|
60
|
+
POSTGRES_SCHEMA: public
|
|
61
|
+
POSTGRES_USER: ""
|
|
62
|
+
POSTGRES_PASSWORD: ""
|
|
63
|
+
S3_BUCKET: ""
|
|
64
|
+
S3_ACCESS_KEY: ""
|
|
65
|
+
S3_SECRET_KEY: ""
|
|
66
|
+
S3_REGION: ""
|
|
67
|
+
S3_ENDPOINT: ""
|
|
68
|
+
S3_SESSION_TOKEN: ""
|
|
69
|
+
|
|
70
|
+
skills: [framework-workflow, framework-data, framework-packages, framework-outputs]
|
|
71
|
+
sensitive_skills: [framework-sensitive-data]
|
|
72
|
+
|
|
73
|
+
types:
|
|
74
|
+
project:
|
|
75
|
+
directories:
|
|
76
|
+
notebooks: notebooks
|
|
77
|
+
scripts: scripts
|
|
78
|
+
functions: functions
|
|
79
|
+
inputs_raw: inputs/raw
|
|
80
|
+
inputs_intermediate: inputs/intermediate
|
|
81
|
+
inputs_final: inputs/final
|
|
82
|
+
docs: docs
|
|
83
|
+
outputs_notebooks: outputs/notebooks
|
|
84
|
+
outputs_tables: outputs/tables
|
|
85
|
+
outputs_figures: outputs/figures
|
|
86
|
+
outputs_models: outputs/models
|
|
87
|
+
outputs_reports: outputs/reports
|
|
88
|
+
cache: outputs/cache
|
|
89
|
+
scratch: scratch
|
|
90
|
+
logs: logs
|
|
91
|
+
render_dirs: {notebooks: outputs/notebooks, docs: outputs/docs}
|
|
92
|
+
render_dir: outputs/notebooks
|
|
93
|
+
project_sensitive:
|
|
94
|
+
directories:
|
|
95
|
+
functions: functions
|
|
96
|
+
notebooks: notebooks
|
|
97
|
+
scripts: scripts
|
|
98
|
+
inputs_private_raw: inputs/private/raw
|
|
99
|
+
inputs_public_raw: inputs/public/raw
|
|
100
|
+
inputs_private_intermediate: inputs/private/intermediate
|
|
101
|
+
inputs_public_intermediate: inputs/public/intermediate
|
|
102
|
+
inputs_private_final: inputs/private/final
|
|
103
|
+
inputs_public_final: inputs/public/final
|
|
104
|
+
outputs_private_notebooks: outputs/private/notebooks
|
|
105
|
+
outputs_public_notebooks: outputs/public/notebooks
|
|
106
|
+
outputs_private_tables: outputs/private/tables
|
|
107
|
+
outputs_public_tables: outputs/public/tables
|
|
108
|
+
outputs_private_figures: outputs/private/figures
|
|
109
|
+
outputs_public_figures: outputs/public/figures
|
|
110
|
+
outputs_private_models: outputs/private/models
|
|
111
|
+
outputs_public_models: outputs/public/models
|
|
112
|
+
outputs_private_reports: outputs/private/reports
|
|
113
|
+
outputs_public_reports: outputs/public/reports
|
|
114
|
+
cache: outputs/private/cache
|
|
115
|
+
scratch: scratch
|
|
116
|
+
logs: logs
|
|
117
|
+
render_dirs: {notebooks: outputs/private/notebooks, docs: outputs/private/docs}
|
|
118
|
+
render_dir: outputs/public/docs
|
|
119
|
+
course:
|
|
120
|
+
directories:
|
|
121
|
+
data: data
|
|
122
|
+
slides: slides
|
|
123
|
+
assignments: assignments
|
|
124
|
+
course_docs: course_docs
|
|
125
|
+
readings: readings
|
|
126
|
+
notebooks: modules
|
|
127
|
+
cache: cache
|
|
128
|
+
render_dirs:
|
|
129
|
+
slides: rendered/slides
|
|
130
|
+
assignments: rendered/assignments
|
|
131
|
+
course_docs: rendered/course_docs
|
|
132
|
+
modules: rendered/modules
|
|
133
|
+
render_dir: rendered/slides
|
|
134
|
+
presentation:
|
|
135
|
+
directories:
|
|
136
|
+
presentation_source: presentation.qmd
|
|
137
|
+
rendered_slides: "."
|
|
138
|
+
data: data
|
|
139
|
+
cache: cache
|
|
140
|
+
render_dirs: {}
|
|
141
|
+
render_dir: "."
|
|
142
|
+
bare:
|
|
143
|
+
directories: {}
|
|
144
|
+
render_dirs: {}
|