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/settings.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Reading and writing project settings (settings.yml and its split files).
|
|
2
|
+
|
|
3
|
+
This follows ``settings_read()`` in the R package so that one settings.yml
|
|
4
|
+
gives the same values in both languages:
|
|
5
|
+
|
|
6
|
+
- A file with a ``default:`` section (or another common environment name at
|
|
7
|
+
the top level) has environment sections. The active environment is merged
|
|
8
|
+
over ``default``: mappings merge by key, and anything else is replaced.
|
|
9
|
+
- The project's ``.env`` is loaded before split files are read.
|
|
10
|
+
- A top-level value ending in ``.yml`` or ``.yaml`` names a split file, which
|
|
11
|
+
is read, merged in, and may itself name split files.
|
|
12
|
+
- ``env("VAR")``, ``env("VAR", "default")``, and ``!expr Sys.getenv(...)``
|
|
13
|
+
are replaced with environment variable values. Any other ``!expr`` is R code,
|
|
14
|
+
which Python cannot run, so it raises an error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import warnings
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from framework import _yaml, dotenv
|
|
27
|
+
from framework.errors import FrameworkWarning, SettingsError
|
|
28
|
+
from framework.project import find_settings_file
|
|
29
|
+
|
|
30
|
+
_ENV_KEYWORDS = frozenset({"default", "production", "development", "test", "testing", "staging"})
|
|
31
|
+
_STANDARD_SECTIONS = ("data", "connections", "git", "security", "packages", "directories")
|
|
32
|
+
_SPLIT_FILE = re.compile(r"\.ya?ml$", re.IGNORECASE)
|
|
33
|
+
_ENV_CALL = re.compile(r'^env\("(.*?)"(?:,\s*"(.*?)")?\)$')
|
|
34
|
+
_EXPR = re.compile(r"^!expr\s+")
|
|
35
|
+
_SYS_GETENV = re.compile(
|
|
36
|
+
r"""^Sys\.getenv\(\s*(['"])(.*?)\1\s*(?:,\s*(?:unset\s*=\s*)?(['"])(.*?)\3\s*)?\)$"""
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_MISSING = object()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SettingsWarning(FrameworkWarning):
|
|
43
|
+
"""A settings file has a conflict that Framework resolved."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def active_environment() -> str:
|
|
47
|
+
"""The settings environment in use.
|
|
48
|
+
|
|
49
|
+
``FW_CONFIG_ACTIVE``, then R's ``R_CONFIG_ACTIVE`` and ``R_CONFIG_NAME``,
|
|
50
|
+
then ``"default"``.
|
|
51
|
+
"""
|
|
52
|
+
for name in ("FW_CONFIG_ACTIVE", "R_CONFIG_ACTIVE", "R_CONFIG_NAME"):
|
|
53
|
+
found = os.environ.get(name)
|
|
54
|
+
if found:
|
|
55
|
+
return found
|
|
56
|
+
return "default"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _settings_path(settings_file: str | Path | None) -> Path:
|
|
60
|
+
if settings_file is None:
|
|
61
|
+
found = find_settings_file()
|
|
62
|
+
if found is None:
|
|
63
|
+
raise SettingsError("No settings file found. Looking for settings.yml or config.yml")
|
|
64
|
+
return found
|
|
65
|
+
path = Path(settings_file)
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
raise SettingsError(f"Settings file not found: {path}")
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# R semantics ----------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _r_assign(target: dict[str, Any], key: str, value: Any) -> None:
|
|
75
|
+
"""``target[[key]] <- value`` in R: assigning NULL removes the key."""
|
|
76
|
+
if value is None:
|
|
77
|
+
target.pop(key, None)
|
|
78
|
+
else:
|
|
79
|
+
target[key] = value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _r_type(value: Any) -> type | None:
|
|
83
|
+
"""The R atomic type a YAML scalar becomes, or None for anything else."""
|
|
84
|
+
if isinstance(value, bool | int | float | str):
|
|
85
|
+
return type(value)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_r_list(value: Any) -> bool:
|
|
90
|
+
"""Whether R's yaml package reads this value as a list.
|
|
91
|
+
|
|
92
|
+
Mappings are lists. A sequence is an atomic vector when it is non-empty
|
|
93
|
+
and every element is a scalar of the same type, and a list otherwise.
|
|
94
|
+
"""
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
return True
|
|
97
|
+
if not isinstance(value, list):
|
|
98
|
+
return False
|
|
99
|
+
types = {_r_type(item) for item in value}
|
|
100
|
+
return not value or None in types or len(types) > 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def modify_list(base: Any, update: Any) -> Any:
|
|
104
|
+
"""R's ``utils::modifyList()``.
|
|
105
|
+
|
|
106
|
+
Named entries of ``update`` replace those in ``base``, recursing when both
|
|
107
|
+
values are R lists, and a NULL removes the entry. A sequence of scalars is
|
|
108
|
+
an R vector, so it replaces the old value whole. A sequence of mappings is
|
|
109
|
+
an unnamed R list, which changes nothing when merged into another list.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(update, dict):
|
|
112
|
+
return base
|
|
113
|
+
if not isinstance(base, dict):
|
|
114
|
+
# An unnamed list gains the named entries, as in R.
|
|
115
|
+
base = {} if not base else {str(i + 1): item for i, item in enumerate(base)}
|
|
116
|
+
result = dict(base)
|
|
117
|
+
for key, value in update.items():
|
|
118
|
+
if key in result and _is_r_list(result[key]) and _is_r_list(value):
|
|
119
|
+
result[key] = modify_list(result[key], value)
|
|
120
|
+
else:
|
|
121
|
+
_r_assign(result, key, value)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def merge_env(base: Any, override: Any) -> Any:
|
|
126
|
+
"""Merge an environment section over ``default``, as R's ``.fw_merge_env()`` does.
|
|
127
|
+
|
|
128
|
+
Mappings merge by key, recursively, and a null value removes the key.
|
|
129
|
+
Anything else, including every sequence, replaces the old value whole.
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(base, dict) or not isinstance(override, dict):
|
|
132
|
+
return override
|
|
133
|
+
result = dict(base)
|
|
134
|
+
for key, value in override.items():
|
|
135
|
+
if value is None:
|
|
136
|
+
result.pop(key, None)
|
|
137
|
+
elif isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
138
|
+
result[key] = merge_env(result[key], value)
|
|
139
|
+
else:
|
|
140
|
+
result[key] = value
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _has_environment_sections(config: Any) -> bool:
|
|
145
|
+
if not isinstance(config, dict) or not config:
|
|
146
|
+
return False
|
|
147
|
+
return any(key in _ENV_KEYWORDS for key in config)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _select_environment(raw: Any, environment: str, label: str, *, warn: bool) -> Any:
|
|
151
|
+
if not _has_environment_sections(raw):
|
|
152
|
+
return raw
|
|
153
|
+
if "default" not in raw:
|
|
154
|
+
raise SettingsError(f"{label} has environment sections but no 'default' environment")
|
|
155
|
+
config = raw["default"]
|
|
156
|
+
if environment != "default":
|
|
157
|
+
if environment in raw:
|
|
158
|
+
config = merge_env(config, raw[environment])
|
|
159
|
+
elif warn:
|
|
160
|
+
warnings.warn(
|
|
161
|
+
f"Environment '{environment}' not found in config, using 'default'",
|
|
162
|
+
SettingsWarning,
|
|
163
|
+
stacklevel=4,
|
|
164
|
+
)
|
|
165
|
+
return config
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Expressions ----------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _evaluate(value: Any) -> Any:
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
return {key: _evaluate(item) for key, item in value.items()}
|
|
174
|
+
if isinstance(value, list):
|
|
175
|
+
return [_evaluate(item) for item in value]
|
|
176
|
+
if not isinstance(value, str):
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
if _EXPR.match(value):
|
|
180
|
+
code = _EXPR.sub("", value, count=1).strip()
|
|
181
|
+
call = _SYS_GETENV.match(code)
|
|
182
|
+
if call is None:
|
|
183
|
+
raise SettingsError(
|
|
184
|
+
f"Cannot evaluate '!expr {code}' in Python. Settings shared with Python can use "
|
|
185
|
+
'env("VAR") or env("VAR", "default"), or !expr Sys.getenv("VAR").'
|
|
186
|
+
)
|
|
187
|
+
return os.environ.get(call.group(2), call.group(4) or "")
|
|
188
|
+
|
|
189
|
+
call = _ENV_CALL.match(value)
|
|
190
|
+
if call is not None:
|
|
191
|
+
return os.environ.get(call.group(1), call.group(2) or "")
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Split files ----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_file_reference(value: Any) -> bool:
|
|
199
|
+
return isinstance(value, str) and bool(_SPLIT_FILE.search(value))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _resolve_split_files(
|
|
203
|
+
config: Any,
|
|
204
|
+
environment: str,
|
|
205
|
+
parent: Path,
|
|
206
|
+
visited: tuple[Path, ...],
|
|
207
|
+
config_root: Path,
|
|
208
|
+
main_keys: frozenset[str],
|
|
209
|
+
) -> Any:
|
|
210
|
+
if not isinstance(config, dict):
|
|
211
|
+
return config
|
|
212
|
+
|
|
213
|
+
file_refs: set[str] = set()
|
|
214
|
+
for key in list(config):
|
|
215
|
+
value = config.get(key, _MISSING)
|
|
216
|
+
if not _is_file_reference(value):
|
|
217
|
+
continue
|
|
218
|
+
file_refs.add(key)
|
|
219
|
+
|
|
220
|
+
reference = str(value)
|
|
221
|
+
candidate = Path(reference)
|
|
222
|
+
split_path = (candidate if candidate.is_absolute() else config_root / candidate).resolve()
|
|
223
|
+
if not split_path.is_file():
|
|
224
|
+
raise SettingsError(f"{reference} not found (referenced from {parent.name})")
|
|
225
|
+
if split_path in visited:
|
|
226
|
+
chain = " -> ".join(str(p) for p in (*visited, split_path))
|
|
227
|
+
raise SettingsError(f"Circular reference detected: {chain}")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
raw = _yaml.read(split_path)
|
|
231
|
+
except Exception as error:
|
|
232
|
+
raise SettingsError(f"Failed to parse split file '{reference}': {error}") from error
|
|
233
|
+
|
|
234
|
+
split = _select_environment(raw, environment, f"Split file '{reference}'", warn=False)
|
|
235
|
+
split = _resolve_split_files(
|
|
236
|
+
split, environment, split_path, (*visited, split_path), config_root, main_keys
|
|
237
|
+
)
|
|
238
|
+
split = _evaluate(split)
|
|
239
|
+
if not isinstance(split, dict):
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
for split_key, split_value in split.items():
|
|
243
|
+
if split_key == key:
|
|
244
|
+
_r_assign(config, split_key, split_value)
|
|
245
|
+
elif split_key in config:
|
|
246
|
+
if split_key in file_refs:
|
|
247
|
+
continue
|
|
248
|
+
if _is_file_reference(config[split_key]):
|
|
249
|
+
# A file reference processed later, not a conflict.
|
|
250
|
+
_r_assign(config, split_key, split_value)
|
|
251
|
+
elif split_key == "options":
|
|
252
|
+
if isinstance(config["options"], dict) and isinstance(split_value, dict):
|
|
253
|
+
config["options"] = modify_list(split_value, config["options"])
|
|
254
|
+
elif split_key in main_keys:
|
|
255
|
+
warnings.warn(
|
|
256
|
+
f"Key '{split_key}' defined in both main config and '{reference}'. "
|
|
257
|
+
"Using value from main config.",
|
|
258
|
+
SettingsWarning,
|
|
259
|
+
stacklevel=4,
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
warnings.warn(
|
|
263
|
+
f"Key '{split_key}' already defined, ignoring value from '{reference}'",
|
|
264
|
+
SettingsWarning,
|
|
265
|
+
stacklevel=4,
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
_r_assign(config, split_key, split_value)
|
|
269
|
+
return config
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
_CONNECTION_KEYS = ("databases", "storage_buckets", "default_database", "default_storage_bucket")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _lift_connections(config: dict[str, Any]) -> dict[str, Any]:
|
|
276
|
+
"""Move connection keys that landed at the top level under ``connections``.
|
|
277
|
+
|
|
278
|
+
Projects created by R 2.0 before a fix wrote ``settings/connections.yml``
|
|
279
|
+
without its ``connections:`` wrapper, so its keys merged into the top
|
|
280
|
+
level. R's ``settings_read()`` moves them back, and so does Python. A
|
|
281
|
+
value already under ``connections`` wins.
|
|
282
|
+
"""
|
|
283
|
+
stray = [key for key in _CONNECTION_KEYS if key in config]
|
|
284
|
+
if not stray:
|
|
285
|
+
return config
|
|
286
|
+
connections = config.get("connections")
|
|
287
|
+
if not isinstance(connections, dict):
|
|
288
|
+
connections = {}
|
|
289
|
+
for key in stray:
|
|
290
|
+
current = connections.get(key)
|
|
291
|
+
if current is None or (isinstance(current, dict | list) and not current):
|
|
292
|
+
connections[key] = config[key]
|
|
293
|
+
del config[key]
|
|
294
|
+
config["connections"] = connections
|
|
295
|
+
return config
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Public API -----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def settings_read(
|
|
302
|
+
settings_file: str | Path | None = None, environment: str | None = None
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Read the project settings, with environment sections and split files resolved.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
settings_file: The settings file. Default: settings.yml (or config.yml)
|
|
308
|
+
in the current folder or the nearest parent folder that has one.
|
|
309
|
+
environment: The environment section to merge over ``default``.
|
|
310
|
+
Default: ``active_environment()``.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The settings as a dict. The ``data``, ``connections``, ``git``,
|
|
314
|
+
``security``, ``packages``, ``directories``, and ``options`` sections
|
|
315
|
+
are always present.
|
|
316
|
+
"""
|
|
317
|
+
path = _settings_path(settings_file)
|
|
318
|
+
active = environment or active_environment()
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
raw = _yaml.read(path)
|
|
322
|
+
except Exception as error:
|
|
323
|
+
raise SettingsError(f"Failed to parse settings file '{path}': {error}") from error
|
|
324
|
+
|
|
325
|
+
config = _select_environment(raw, active, f"Settings file '{path}'", warn=True)
|
|
326
|
+
if config is None:
|
|
327
|
+
config = {}
|
|
328
|
+
if not isinstance(config, dict):
|
|
329
|
+
raise SettingsError(f"Settings file '{path}' must contain a mapping")
|
|
330
|
+
|
|
331
|
+
root = path.parent
|
|
332
|
+
dotenv_location = config.get("dotenv_location")
|
|
333
|
+
if dotenv_location is not None:
|
|
334
|
+
env_path = Path(str(dotenv_location))
|
|
335
|
+
if not env_path.is_absolute():
|
|
336
|
+
env_path = root / env_path
|
|
337
|
+
if env_path.is_dir():
|
|
338
|
+
env_path = env_path / ".env"
|
|
339
|
+
else:
|
|
340
|
+
env_path = root / ".env"
|
|
341
|
+
if env_path.is_file():
|
|
342
|
+
try:
|
|
343
|
+
dotenv.load(env_path)
|
|
344
|
+
except SettingsError as error:
|
|
345
|
+
warnings.warn(f"Failed to load .env file: {error}", SettingsWarning, stacklevel=2)
|
|
346
|
+
|
|
347
|
+
resolved_path = path.resolve()
|
|
348
|
+
config = _resolve_split_files(
|
|
349
|
+
config, active, resolved_path, (), resolved_path.parent, frozenset(config)
|
|
350
|
+
)
|
|
351
|
+
config = _lift_connections(config)
|
|
352
|
+
|
|
353
|
+
for section in (*_STANDARD_SECTIONS, "options"):
|
|
354
|
+
if config.get(section) is None:
|
|
355
|
+
config[section] = {}
|
|
356
|
+
|
|
357
|
+
evaluated: dict[str, Any] = _evaluate(config)
|
|
358
|
+
return evaluated
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def settings(
|
|
362
|
+
key: str | None = None, default: Any = None, settings_file: str | Path | None = None
|
|
363
|
+
) -> Any:
|
|
364
|
+
"""A settings value by dot-notation key, such as ``"directories.notebooks"``.
|
|
365
|
+
|
|
366
|
+
A one-part key is looked up in ``directories`` first, so
|
|
367
|
+
``settings("notebooks")`` returns the notebooks folder. With no key, the
|
|
368
|
+
whole settings dict is returned. A missing key returns ``default``.
|
|
369
|
+
"""
|
|
370
|
+
config = settings_read(settings_file)
|
|
371
|
+
if key is None:
|
|
372
|
+
return config
|
|
373
|
+
|
|
374
|
+
parts = key.split(".")
|
|
375
|
+
if len(parts) == 1:
|
|
376
|
+
directories = config.get("directories")
|
|
377
|
+
if isinstance(directories, dict) and directories.get(key) is not None:
|
|
378
|
+
return directories[key]
|
|
379
|
+
options = config.get("options")
|
|
380
|
+
if isinstance(options, dict) and options.get(f"{key}_dir") is not None:
|
|
381
|
+
return options[f"{key}_dir"]
|
|
382
|
+
|
|
383
|
+
value: Any = config
|
|
384
|
+
for part in parts:
|
|
385
|
+
if isinstance(value, dict) and part in value:
|
|
386
|
+
value = value[part]
|
|
387
|
+
else:
|
|
388
|
+
return default
|
|
389
|
+
return value
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def settings_write(
|
|
393
|
+
values: Mapping[str, Any],
|
|
394
|
+
settings_file: str | Path | None = None,
|
|
395
|
+
section: str | None = None,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Write project settings.
|
|
398
|
+
|
|
399
|
+
With ``section``, only that section of the active environment is
|
|
400
|
+
replaced. When the section is stored in a split file under ``settings/``,
|
|
401
|
+
the values are merged into that file instead. Without ``section``, the
|
|
402
|
+
whole file is replaced, wrapped in a ``default:`` environment.
|
|
403
|
+
|
|
404
|
+
Comments in a rewritten file are not kept.
|
|
405
|
+
"""
|
|
406
|
+
if settings_file is None:
|
|
407
|
+
found = find_settings_file()
|
|
408
|
+
path = found if found is not None else Path("settings.yml")
|
|
409
|
+
else:
|
|
410
|
+
path = Path(settings_file)
|
|
411
|
+
|
|
412
|
+
if section is None:
|
|
413
|
+
data = dict(values)
|
|
414
|
+
if "default" not in data:
|
|
415
|
+
data = {"default": data}
|
|
416
|
+
_yaml.write(path, data)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
if not path.is_file():
|
|
420
|
+
raise SettingsError(
|
|
421
|
+
f"Settings file '{path}' does not exist. "
|
|
422
|
+
"Call settings_write(values) without 'section' to create it first."
|
|
423
|
+
)
|
|
424
|
+
current = _yaml.read(path) or {}
|
|
425
|
+
environment = active_environment()
|
|
426
|
+
env_config = current.setdefault(environment, {})
|
|
427
|
+
if env_config is None:
|
|
428
|
+
env_config = current[environment] = {}
|
|
429
|
+
|
|
430
|
+
reference = env_config.get(section)
|
|
431
|
+
if isinstance(reference, str) and reference.startswith("settings/"):
|
|
432
|
+
split_path = path.parent / reference
|
|
433
|
+
if not split_path.is_file():
|
|
434
|
+
raise SettingsError(f"Split file '{reference}' does not exist")
|
|
435
|
+
split = _yaml.read(split_path) or {}
|
|
436
|
+
_yaml.write(split_path, modify_list(split, dict(values)))
|
|
437
|
+
else:
|
|
438
|
+
env_config[section] = dict(values)
|
|
439
|
+
_yaml.write(path, current)
|
|
File without changes
|
framework/spec/check.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Checking a resolved project spec before anything is written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from framework import _yaml
|
|
10
|
+
from framework.spec.merge import MAP_PATHS
|
|
11
|
+
|
|
12
|
+
REQUIRED = (
|
|
13
|
+
"spec_version",
|
|
14
|
+
"name",
|
|
15
|
+
"type",
|
|
16
|
+
"language",
|
|
17
|
+
"author",
|
|
18
|
+
"directories",
|
|
19
|
+
"extra_directories",
|
|
20
|
+
"render_dirs",
|
|
21
|
+
"packages",
|
|
22
|
+
"git",
|
|
23
|
+
"ai",
|
|
24
|
+
"scaffold",
|
|
25
|
+
"connections",
|
|
26
|
+
"security",
|
|
27
|
+
"env",
|
|
28
|
+
"data_hooks",
|
|
29
|
+
"files",
|
|
30
|
+
"skills",
|
|
31
|
+
)
|
|
32
|
+
OPTIONAL = ("quarto", "source")
|
|
33
|
+
|
|
34
|
+
# Assistant -> (skills folder, stub file). Adding an assistant is one row.
|
|
35
|
+
ASSISTANTS: dict[str, tuple[str, str | None]] = {
|
|
36
|
+
"claude": (".claude/skills", "CLAUDE.md"),
|
|
37
|
+
"codex": (".agents/skills", None),
|
|
38
|
+
"copilot": (".agents/skills", ".github/copilot-instructions.md"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_TYPES = ("project", "project_sensitive", "course", "presentation", "bare")
|
|
42
|
+
_SOURCES = ("pypi", "github")
|
|
43
|
+
_PACKAGE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
44
|
+
_SKILL = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
45
|
+
_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
46
|
+
_SECRET_NAME = re.compile(
|
|
47
|
+
r"PASSWORD|SECRET|TOKEN|ACCESS_KEY|API_KEY|PRIVATE_KEY|CREDENTIAL", re.IGNORECASE
|
|
48
|
+
)
|
|
49
|
+
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def secret_env_values(raw: str | None = None, variables: Any = None) -> list[str]:
|
|
53
|
+
"""Secret-looking variables that have a value, as R's ``.fw_secret_env_values()``.
|
|
54
|
+
|
|
55
|
+
``variables`` maps names to values. ``raw`` is .env text; ``export``,
|
|
56
|
+
quotes, and inline comments are handled, so ``KEY= # note`` is empty.
|
|
57
|
+
"""
|
|
58
|
+
found: list[str] = []
|
|
59
|
+
for name, value in (variables if isinstance(variables, dict) else {}).items():
|
|
60
|
+
if _SECRET_NAME.search(str(name)) and value not in (None, ""):
|
|
61
|
+
found.append(str(name))
|
|
62
|
+
for line in (raw or "").split("\n"):
|
|
63
|
+
line = re.sub(r"^export\s+", "", line.strip())
|
|
64
|
+
match = _ENV_LINE.match(line)
|
|
65
|
+
if match is None:
|
|
66
|
+
continue
|
|
67
|
+
value = re.sub(r"(^|\s+)#.*$", "", match.group(2).strip())
|
|
68
|
+
value = re.sub(r"^[\"']|[\"']$", "", value)
|
|
69
|
+
if _SECRET_NAME.search(match.group(1)) and value:
|
|
70
|
+
found.append(match.group(1))
|
|
71
|
+
return list(dict.fromkeys(found))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_HOOK = re.compile(r"^[^:]+\.py::[A-Za-z_][A-Za-z0-9_.]*$")
|
|
75
|
+
_NOTEBOOK_FORMATS = ("quarto", "jupyter")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_safe_path(path: Any) -> bool:
|
|
79
|
+
"""A relative path that stays inside the project."""
|
|
80
|
+
return (
|
|
81
|
+
isinstance(path, str)
|
|
82
|
+
and bool(path)
|
|
83
|
+
and not re.match(r"^(/|[A-Za-z]:|~)", path)
|
|
84
|
+
and "\\" not in path
|
|
85
|
+
and ".." not in path.split("/")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def skill_name(content: str) -> str | None:
|
|
90
|
+
"""The ``name`` in a SKILL.md file's frontmatter."""
|
|
91
|
+
lines = re.split(r"\r?\n", content)
|
|
92
|
+
if not lines or lines[0].strip() != "---":
|
|
93
|
+
return None
|
|
94
|
+
end = next((i for i, line in enumerate(lines[1:], start=1) if line.strip() == "---"), None)
|
|
95
|
+
if end is None:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
front = _yaml.load("\n".join(lines[1:end]))
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
name = front.get("name") if isinstance(front, dict) else None
|
|
102
|
+
return name if isinstance(name, str) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def shipped_skill(name: str) -> bool:
|
|
106
|
+
"""Whether this package ships a skill with this name."""
|
|
107
|
+
return resources.files("framework").joinpath(f"templates/skills/{name}/SKILL.md").is_file()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get(spec: dict[str, Any], path: tuple[str, ...]) -> Any:
|
|
111
|
+
value: Any = spec
|
|
112
|
+
for part in path:
|
|
113
|
+
value = value.get(part) if isinstance(value, dict) else None
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _dict(value: Any) -> dict[str, Any]:
|
|
118
|
+
return value if isinstance(value, dict) else {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def spec_check(spec: dict[str, Any]) -> list[str]:
|
|
122
|
+
"""Problems that would stop ``project_create()``. An empty list means the spec is valid."""
|
|
123
|
+
problems: list[str] = []
|
|
124
|
+
add = problems.append
|
|
125
|
+
|
|
126
|
+
for key in REQUIRED:
|
|
127
|
+
if key not in spec:
|
|
128
|
+
add(f"missing section '{key}'")
|
|
129
|
+
for key in spec:
|
|
130
|
+
if key not in REQUIRED and key not in OPTIONAL:
|
|
131
|
+
add(f"unknown section '{key}'")
|
|
132
|
+
|
|
133
|
+
if spec.get("spec_version") != 1:
|
|
134
|
+
add(f"spec_version must be 1, got {spec.get('spec_version')}")
|
|
135
|
+
if spec.get("type") not in _TYPES:
|
|
136
|
+
add(f"unknown project type '{spec.get('type')}'")
|
|
137
|
+
if spec.get("language") != "python":
|
|
138
|
+
add(f"language must be 'python' for the Python package, got '{spec.get('language')}'")
|
|
139
|
+
|
|
140
|
+
for map_path in MAP_PATHS:
|
|
141
|
+
value = _get(spec, map_path)
|
|
142
|
+
if value not in (None, {}) and not isinstance(value, dict):
|
|
143
|
+
add(f"{'.'.join(map_path)} must be an object keyed by name, not a list")
|
|
144
|
+
|
|
145
|
+
def check_path(path: Any, where: str) -> None:
|
|
146
|
+
if not is_safe_path(path):
|
|
147
|
+
add(f"{where}: path '{path}' must be relative and stay inside the project")
|
|
148
|
+
|
|
149
|
+
for key, path in _dict(spec.get("directories")).items():
|
|
150
|
+
check_path(path, f"directories.{key}")
|
|
151
|
+
for key, path in _dict(spec.get("render_dirs")).items():
|
|
152
|
+
check_path(path, f"render_dirs.{key}")
|
|
153
|
+
for key, entry in _dict(spec.get("extra_directories")).items():
|
|
154
|
+
path = _dict(entry).get("path")
|
|
155
|
+
if path is None:
|
|
156
|
+
add(f"extra_directories.{key}: missing path")
|
|
157
|
+
else:
|
|
158
|
+
check_path(path, f"extra_directories.{key}")
|
|
159
|
+
for path, entry in _dict(spec.get("files")).items():
|
|
160
|
+
check_path(path, "files")
|
|
161
|
+
if not isinstance(_dict(entry).get("content"), str):
|
|
162
|
+
add(f"files.{path}: missing content")
|
|
163
|
+
|
|
164
|
+
for name, entry in _dict(_get(spec, ("packages", "default_packages"))).items():
|
|
165
|
+
if not _PACKAGE.match(name):
|
|
166
|
+
add(f"packages.default_packages: invalid package name '{name}'")
|
|
167
|
+
if _dict(entry).get("source") not in _SOURCES:
|
|
168
|
+
add(
|
|
169
|
+
f"packages.default_packages.{name}: "
|
|
170
|
+
"source must be pypi or github for a Python project"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for assistant in _dict(_get(spec, ("ai", "assistants"))):
|
|
174
|
+
if assistant not in ASSISTANTS:
|
|
175
|
+
add(f"ai.assistants: unknown assistant '{assistant}'")
|
|
176
|
+
if _get(spec, ("ai", "agents_md")) not in ("stub", "none"):
|
|
177
|
+
add("ai.agents_md must be 'stub' or 'none'")
|
|
178
|
+
|
|
179
|
+
notebook_format = _get(spec, ("scaffold", "notebook_format"))
|
|
180
|
+
if notebook_format is not None and notebook_format not in _NOTEBOOK_FORMATS:
|
|
181
|
+
add(
|
|
182
|
+
"scaffold.notebook_format must be quarto or jupyter for a Python project, "
|
|
183
|
+
f"not '{notebook_format}'"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
for name, entry in _dict(spec.get("skills")).items():
|
|
187
|
+
entry = _dict(entry)
|
|
188
|
+
if not _SKILL.match(name):
|
|
189
|
+
add(f"skills: invalid skill name '{name}'")
|
|
190
|
+
if entry.get("package") is True:
|
|
191
|
+
if not shipped_skill(name):
|
|
192
|
+
add(f"skills.{name}: not shipped with this version of framework-py")
|
|
193
|
+
elif isinstance(entry.get("content"), str):
|
|
194
|
+
declared = skill_name(entry["content"])
|
|
195
|
+
if declared != name:
|
|
196
|
+
add(f"skills.{name}: SKILL.md frontmatter name is '{declared or 'missing'}'")
|
|
197
|
+
for path in _dict(entry.get("files")):
|
|
198
|
+
check_path(path, f"skills.{name}.files")
|
|
199
|
+
else:
|
|
200
|
+
add(f"skills.{name}: needs either package: true or content")
|
|
201
|
+
|
|
202
|
+
for name, value in _dict(spec.get("env")).items():
|
|
203
|
+
if not _ENV_NAME.match(name):
|
|
204
|
+
add(f"env: invalid variable name '{name}'")
|
|
205
|
+
elif _SECRET_NAME.search(name) and value not in (None, ""):
|
|
206
|
+
add(f"env.{name}: secrets must not be stored in the spec; leave the value empty")
|
|
207
|
+
for key, hook in _dict(spec.get("data_hooks")).items():
|
|
208
|
+
if not isinstance(hook, str) or not _HOOK.match(hook):
|
|
209
|
+
add(f"data_hooks.{key}: expected 'path/to/file.py::function'")
|
|
210
|
+
|
|
211
|
+
return problems
|