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/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
@@ -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