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/create.py ADDED
@@ -0,0 +1,579 @@
1
+ """Creating Python projects from a resolved spec, a layers file, or ``new()``.
2
+
3
+ Python builds the project itself, the way the R package does. The settings
4
+ files, data catalog, ``framework.db``, folder layout, AI files, and Quarto
5
+ files use the shared formats. The language-specific files are
6
+ ``scaffold.py``, ``pyproject.toml``, and Python notebook stubs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from collections.abc import Mapping, Sequence
18
+ from datetime import date
19
+ from importlib import resources
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from framework import _yaml, db
24
+ from framework._log import inform, show_messages
25
+ from framework.ai import write_ai_files
26
+ from framework.config_home import global_settings, project_register
27
+ from framework.errors import FrameworkError
28
+ from framework.quarto import quarto_generate_all
29
+ from framework.spec.check import spec_check
30
+ from framework.spec.merge import SpecError
31
+ from framework.spec.resolve import TYPES, author_normalize, spec_resolve
32
+
33
+ CHECKOUT_ALLOWLIST = frozenset({".git", ".github", ".framework", "README.md", "LICENSE"})
34
+ _FILE_LIKE = re.compile(r"\.(qmd|Rmd|R|md|py|ipynb)$")
35
+ _TEMPLATE_VARS = ("project_name", "author", "author_email", "affiliation", "date")
36
+ _SPLIT_TYPES = ("project", "project_sensitive")
37
+ # Lines that ignore .env (or re-include it with a leading "!"), as in R.
38
+ _ENV_RULE = re.compile(r"^(!?)/?(\*\*/)?(\.env\*?|\*\.env)$")
39
+
40
+
41
+ def _version() -> str:
42
+ from framework import __version__
43
+
44
+ return __version__
45
+
46
+
47
+ def kebab_case(name: str) -> str:
48
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "project"
49
+
50
+
51
+ def _write(root: Path, relative: str, content: str) -> None:
52
+ target = root / relative
53
+ target.parent.mkdir(parents=True, exist_ok=True)
54
+ target.write_text(content, encoding="utf-8")
55
+
56
+
57
+ def fill_template(content: str, spec: Mapping[str, Any]) -> str:
58
+ """Replace ``{{ variable }}`` for the fixed set of variables only.
59
+
60
+ Other double-brace text, such as Quarto shortcodes, is left alone.
61
+ """
62
+ author = spec.get("author") or {}
63
+ values = {
64
+ "project_name": spec.get("name", ""),
65
+ "author": author.get("name", ""),
66
+ "author_email": author.get("email", ""),
67
+ "affiliation": author.get("affiliation", ""),
68
+ "date": f"{date.today():%Y-%m-%d}",
69
+ }
70
+ for name in _TEMPLATE_VARS:
71
+ replacement = str(values[name])
72
+ # Escape backslashes: re.sub would read them as group references.
73
+ content = re.sub(r"\{\{\s*" + name + r"\s*\}\}", replacement.replace("\\", "\\\\"), content)
74
+ return content
75
+
76
+
77
+ def _stub(name: str) -> str:
78
+ return resources.files("framework").joinpath(f"templates/stubs/{name}").read_text("utf-8")
79
+
80
+
81
+ # The steps --------------------------------------------------------------------
82
+
83
+
84
+ def _assert_checkout(root: Path) -> None:
85
+ if not root.exists():
86
+ return
87
+ unexpected = sorted(p.name for p in root.iterdir() if p.name not in CHECKOUT_ALLOWLIST)
88
+ if unexpected:
89
+ raise FrameworkError(
90
+ f"Refusing to create a project in '{root}': it already contains "
91
+ f"{', '.join(unexpected)}. Headless creation only runs in a fresh checkout "
92
+ f"(allowed: {', '.join(sorted(CHECKOUT_ALLOWLIST))})."
93
+ )
94
+
95
+
96
+ def _create_directories(root: Path, spec: Mapping[str, Any]) -> None:
97
+ for path in (spec.get("directories") or {}).values():
98
+ if path and not path.startswith("outputs/") and not _FILE_LIKE.search(path):
99
+ (root / path).mkdir(parents=True, exist_ok=True)
100
+ for entry in (spec.get("extra_directories") or {}).values():
101
+ if entry.get("path"):
102
+ (root / entry["path"]).mkdir(parents=True, exist_ok=True)
103
+ for key, path in (spec.get("render_dirs") or {}).items():
104
+ if path and "notebook" in key.lower() and not _FILE_LIKE.search(path):
105
+ (root / path).mkdir(parents=True, exist_ok=True)
106
+
107
+
108
+ def _sections(spec: Mapping[str, Any]) -> dict[str, Any]:
109
+ """The settings sections a Python project writes, in the shared format."""
110
+ scaffold = spec.get("scaffold") or {}
111
+ git = spec.get("git") or {}
112
+ ai = spec.get("ai") or {}
113
+ return {
114
+ "author": author_normalize(spec.get("author")),
115
+ "git": {
116
+ "enabled": git.get("use_git", True),
117
+ "user_name": "",
118
+ "user_email": "",
119
+ "hooks": git.get("hooks") or {},
120
+ },
121
+ "ai": {
122
+ "enabled": ai.get("enabled", False),
123
+ "canonical_file": "AGENTS.md",
124
+ "assistants": list(ai.get("assistants") or {}),
125
+ },
126
+ "scaffold": {
127
+ "seed_on_scaffold": scaffold.get("seed_on_scaffold", False),
128
+ "seed": scaffold.get("seed", ""),
129
+ "source_all_functions": scaffold.get("source_all_functions", True),
130
+ "notebook_format": scaffold.get("notebook_format", "quarto"),
131
+ },
132
+ "quarto": spec.get("quarto") or {},
133
+ "connections": spec.get("connections") or {},
134
+ }
135
+
136
+
137
+ def _write_settings(root: Path, spec: Mapping[str, Any]) -> None:
138
+ sections = _sections(spec)
139
+ main: dict[str, Any] = {
140
+ "project_name": spec["name"],
141
+ "project_type": spec["type"],
142
+ "language": spec["language"],
143
+ "directories": spec.get("directories") or {},
144
+ }
145
+ extra = [
146
+ {"key": key, "label": entry.get("label", key), "path": entry["path"]}
147
+ for key, entry in (spec.get("extra_directories") or {}).items()
148
+ ]
149
+ if extra:
150
+ main["extra_directories"] = extra
151
+ main["render_dirs"] = spec.get("render_dirs") or {}
152
+
153
+ if spec["type"] in _SPLIT_TYPES:
154
+ for section in ("author", "git", "ai", "scaffold", "quarto"):
155
+ main[section] = f"settings/{section}.yml"
156
+ _yaml.write(root / "settings" / f"{section}.yml", {section: sections[section]})
157
+ main["connections"] = "settings/connections.yml"
158
+ _yaml.write(root / "settings/connections.yml", {"connections": sections["connections"]})
159
+ main["data"] = "settings/data.yml"
160
+ _yaml.write(root / "settings/data.yml", {"data": {}})
161
+ else:
162
+ for section in ("author", "quarto", "git", "ai", "scaffold"):
163
+ main[section] = sections[section]
164
+ main["data"] = {}
165
+ main["connections"] = sections["connections"]
166
+
167
+ main["security"] = spec.get("security") or {"sensitive": False}
168
+ if spec.get("data_hooks"):
169
+ main["data_hooks"] = spec["data_hooks"]
170
+ _yaml.write(root / "settings.yml", {"default": main})
171
+
172
+
173
+ def _write_env(root: Path, spec: Mapping[str, Any]) -> None:
174
+ lines = [
175
+ f"{key.upper()}={value if value is not None else ''}"
176
+ for key, value in (spec.get("env") or {}).items()
177
+ ]
178
+ _write(root, ".env", "\n".join(lines) + ("\n" if lines else ""))
179
+
180
+
181
+ def _env_rule(line: str) -> bool | None:
182
+ """Whether a .gitignore line ignores ``.env`` (True), re-includes it (False), or neither."""
183
+ match = _ENV_RULE.match(line.strip())
184
+ return None if match is None else not match.group(1)
185
+
186
+
187
+ def ensure_secrets_ignored(root: Path) -> bool:
188
+ """Make sure ``.gitignore`` ignores ``.env``. Returns whether a rule was added.
189
+
190
+ The last rule that mentions ``.env`` decides, as in git, so a layer's
191
+ ``!.env`` cannot bring the secrets file back into a commit.
192
+ """
193
+ path = root / ".gitignore"
194
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
195
+ rules = [rule for line in text.splitlines() if (rule := _env_rule(line)) is not None]
196
+ if rules and rules[-1]:
197
+ return False
198
+ if text and not text.endswith("\n"):
199
+ text += "\n"
200
+ path.write_text(text + "\n# Secrets (added by Framework)\n.env\n", encoding="utf-8")
201
+ inform(" Added .env to .gitignore")
202
+ return True
203
+
204
+
205
+ def _scaffold_py(spec: Mapping[str, Any]) -> str:
206
+ return (
207
+ '"""Project setup, run by framework.scaffold() after the settings are loaded.\n\n'
208
+ "Put code here that every notebook and script needs, such as plotting styles.\n"
209
+ "Random seeds are set from settings/scaffold.yml (seed_on_scaffold and seed).\n"
210
+ '"""\n\n'
211
+ "# import matplotlib.pyplot as plt\n"
212
+ '# plt.style.use("seaborn-v0_8-whitegrid")\n'
213
+ )
214
+
215
+
216
+ def _dependency(name: str, entry: Mapping[str, Any]) -> str:
217
+ if entry.get("source") == "github" and entry.get("repo"):
218
+ ref = f"@{entry['ref']}" if entry.get("ref") else ""
219
+ return f"{name} @ git+https://github.com/{entry['repo']}{ref}"
220
+ version = entry.get("version")
221
+ if version and not re.match(r"^[<>=!~]", str(version)):
222
+ version = f"=={version}"
223
+ return f"{name}{version or ''}"
224
+
225
+
226
+ def _pyproject(spec: Mapping[str, Any]) -> str:
227
+ packages = (spec.get("packages") or {}).get("default_packages") or {}
228
+ dependencies = ["framework-py", *(_dependency(n, e) for n, e in packages.items())]
229
+ lines = [
230
+ "[project]",
231
+ f'name = "{kebab_case(spec["name"])}"',
232
+ 'version = "0.1.0"',
233
+ f"description = {json.dumps(spec['name'])}",
234
+ 'requires-python = ">=3.11"',
235
+ "dependencies = [",
236
+ *(f" {json.dumps(dependency)}," for dependency in dependencies),
237
+ "]",
238
+ "",
239
+ "[tool.uv]",
240
+ "package = false",
241
+ "",
242
+ ]
243
+ return "\n".join(lines)
244
+
245
+
246
+ def _code_workspace() -> str:
247
+ workspace = {
248
+ "folders": [{"path": "."}],
249
+ "settings": {
250
+ "python.defaultInterpreterPath": ".venv/bin/python",
251
+ "files.associations": {"*.qmd": "quarto"},
252
+ },
253
+ }
254
+ return json.dumps(workspace, indent=2) + "\n"
255
+
256
+
257
+ def _presentation(spec: Mapping[str, Any]) -> str:
258
+ author = (spec.get("author") or {}).get("name") or "Your Name"
259
+ text = _stub("notebook-revealjs.qmd")
260
+ return (
261
+ text.replace("{{ title }}", str(spec["name"]))
262
+ .replace("{{ author }}", author)
263
+ .replace("{{ date }}", f"{date.today():%Y-%m-%d}")
264
+ )
265
+
266
+
267
+ def _record(root: Path, spec: Mapping[str, Any]) -> None:
268
+ record = dict(spec)
269
+ source = dict(record.get("source") or {"origin": "python"})
270
+ source["framework_version"] = _version()
271
+ record["source"] = source
272
+ _write(root, ".framework/spec.json", json.dumps(record, indent=2, ensure_ascii=False) + "\n")
273
+
274
+
275
+ def _git_init(root: Path) -> None:
276
+ if shutil.which("git") is None:
277
+ inform(" Git not found - skipping repository initialization")
278
+ return
279
+
280
+ def git(*args: str) -> subprocess.CompletedProcess[str]:
281
+ return subprocess.run(
282
+ ["git", "-C", str(root), *args], capture_output=True, text=True, check=False
283
+ )
284
+
285
+ git("init")
286
+ inform(" Initialized git repository")
287
+ from framework.hooks import git_hooks_install
288
+
289
+ if git_hooks_install(root / "settings.yml", verbose=False):
290
+ inform(" Installed git hooks")
291
+ ensure_secrets_ignored(root)
292
+ if (root / ".env").exists() and git("check-ignore", "-q", ".env").returncode != 0:
293
+ raise FrameworkError(
294
+ ".env is not ignored by .gitignore, so it would be committed. "
295
+ "Fix .gitignore, then run git init yourself."
296
+ )
297
+ git("add", ".")
298
+ if (
299
+ not git("config", "user.name").stdout.strip()
300
+ or not git("config", "user.email").stdout.strip()
301
+ ):
302
+ inform(" Skipping initial commit (git user.name/user.email not set).")
303
+ return
304
+ if git("commit", "-m", "Initial commit from Framework").returncode == 0:
305
+ inform(" Created initial commit")
306
+
307
+
308
+ def _is_temporary(root: Path) -> bool:
309
+ temp = Path(tempfile.gettempdir()).resolve()
310
+ resolved = root.resolve()
311
+ return resolved.is_relative_to(temp) or str(resolved).startswith(("/tmp/", "/private/tmp/"))
312
+
313
+
314
+ # Public API -----------------------------------------------------------------
315
+
316
+
317
+ def project_create(
318
+ spec: Mapping[str, Any], location: str | Path, *, headless: bool = False
319
+ ) -> Path:
320
+ """Create a Python project from a resolved spec.
321
+
322
+ Args:
323
+ spec: A resolved spec, from ``spec_resolve()``.
324
+ location: The project folder. It must not exist, except in headless
325
+ mode, where it may be a fresh checkout holding only ``.git``,
326
+ ``.github``, ``.framework``, ``README.md``, and ``LICENSE``.
327
+ headless: For automation (the framework.pub GitHub Action): no git
328
+ initialization, no registry entry, and no files under
329
+ ``.github/workflows/``.
330
+
331
+ Returns:
332
+ The project folder.
333
+ """
334
+ show_messages()
335
+ problems = spec_check(dict(spec))
336
+ if problems:
337
+ raise SpecError("The project spec is invalid:\n" + "\n".join(f" - {p}" for p in problems))
338
+
339
+ root = Path(location).expanduser()
340
+ files = spec.get("files") or {}
341
+ if headless:
342
+ _assert_checkout(root)
343
+ workflows = sorted(path for path in files if path.startswith(".github/workflows/"))
344
+ if workflows:
345
+ raise FrameworkError(
346
+ "Headless creation cannot write workflow files (GitHub rejects them from "
347
+ f"GITHUB_TOKEN pushes): {', '.join(workflows)}"
348
+ )
349
+ elif root.exists():
350
+ raise FrameworkError(f"Project directory already exists: {root}")
351
+ root.mkdir(parents=True, exist_ok=True)
352
+ inform(f"Creating project in: {root}")
353
+
354
+ bare = spec["type"] == "bare"
355
+ _create_directories(root, spec)
356
+ _write_settings(root, spec)
357
+ if not bare:
358
+ _write_env(root, spec)
359
+ _write(root, ".gitignore", str((spec.get("git") or {}).get("gitignore_content", "")) + "\n")
360
+ ensure_secrets_ignored(root)
361
+ if not bare and (spec.get("ai") or {}).get("enabled"):
362
+ write_ai_files(root, dict(spec))
363
+ if not bare:
364
+ _write(root, "scaffold.py", _scaffold_py(spec))
365
+ _write(root, "pyproject.toml", _pyproject(spec))
366
+ ide = str((spec.get("scaffold") or {}).get("ide") or "")
367
+ if not bare and re.search("vscode|positron", ide, re.IGNORECASE):
368
+ _write(root, f"{kebab_case(spec['name'])}.code-workspace", _code_workspace())
369
+ if spec["type"] == "presentation":
370
+ _write(root, "presentation.qmd", _presentation(spec))
371
+
372
+ # Blueprint files last, so they replace generated files of the same name.
373
+ for relative, entry in files.items():
374
+ content = entry["content"]
375
+ _write(root, relative, fill_template(content, spec) if entry.get("template") else content)
376
+
377
+ if not bare:
378
+ quarto_generate_all(
379
+ root,
380
+ spec["type"],
381
+ spec.get("render_dirs") or {},
382
+ spec.get("quarto") or {},
383
+ spec.get("directories") or {},
384
+ )
385
+ db.ensure_db(root)
386
+ _record(root, spec)
387
+
388
+ if not headless:
389
+ if (spec.get("git") or {}).get("use_git"):
390
+ _git_init(root)
391
+ if not _is_temporary(root):
392
+ project_register(root)
393
+ inform(f"Project created: {root}")
394
+ return root
395
+
396
+
397
+ def project_create_from_layers(
398
+ layers_file: str | Path, path: str | Path = ".", interactive: bool = False
399
+ ) -> Path:
400
+ """Create a project from a layers file (the format framework.pub sends).
401
+
402
+ With ``interactive=False`` (the default) it runs headless for automation,
403
+ as ``project_create(headless=True)`` describes.
404
+ """
405
+ source = Path(layers_file)
406
+ try:
407
+ envelope = json.loads(source.read_text(encoding="utf-8"))
408
+ except (OSError, json.JSONDecodeError) as error:
409
+ raise FrameworkError(f"Failed to read layers file '{source}': {error}") from error
410
+ if envelope.get("spec_version") != 1:
411
+ raise FrameworkError(
412
+ f"Unsupported spec_version in '{source}': {envelope.get('spec_version')} "
413
+ "(this framework reads 1)"
414
+ )
415
+ if not envelope.get("name"):
416
+ raise FrameworkError(f"Layers file '{source}' has no project name")
417
+ resolved = spec_resolve(envelope.get("layers") or [], envelope["name"], envelope.get("author"))
418
+ spec = resolved.spec
419
+ if envelope.get("source") is not None:
420
+ spec["source"] = envelope["source"]
421
+ return project_create(spec, path, headless=not interactive)
422
+
423
+
424
+ def user_layer(type: str) -> dict[str, Any]:
425
+ """The user's defaults from the global settings, as a spec layer.
426
+
427
+ Reads ``author`` and the ``defaults`` section (scaffold, AI assistants,
428
+ git, and the directories for this project type) of ``settings.yml`` in
429
+ ``config_dir()``.
430
+ """
431
+ settings = global_settings()
432
+ raw_defaults = settings.get("defaults")
433
+ defaults: dict[str, Any] = raw_defaults if isinstance(raw_defaults, dict) else {}
434
+ layer: dict[str, Any] = {}
435
+ if isinstance(settings.get("author"), dict):
436
+ layer["author"] = author_normalize(settings["author"])
437
+ scaffold = defaults.get("scaffold")
438
+ if isinstance(scaffold, dict):
439
+ keys = ("seed_on_scaffold", "seed", "source_all_functions", "ide")
440
+ layer["scaffold"] = {k: scaffold[k] for k in keys if k in scaffold}
441
+ if "seed" in layer["scaffold"]:
442
+ layer["scaffold"]["seed"] = str(layer["scaffold"]["seed"])
443
+ ai = defaults.get("ai")
444
+ if isinstance(ai, dict):
445
+ assistants = ai.get("assistants")
446
+ layer["ai"] = {
447
+ key: value for key, value in (("enabled", ai.get("enabled")),) if value is not None
448
+ }
449
+ if isinstance(assistants, list):
450
+ layer["ai"]["assistants"] = {name: True for name in assistants}
451
+ git = defaults.get("git")
452
+ if isinstance(git, dict):
453
+ layer["git"] = {
454
+ k: v
455
+ for k, v in (("use_git", git.get("initialize")), ("hooks", git.get("hooks")))
456
+ if v is not None
457
+ }
458
+ types = settings.get("project_types")
459
+ type_config = types.get(type) if isinstance(types, dict) else None
460
+ if isinstance(type_config, dict) and isinstance(type_config.get("directories"), dict):
461
+ layer["directories"] = type_config["directories"]
462
+ return layer
463
+
464
+
465
+ def new(
466
+ name: str | None = None,
467
+ location: str | Path | None = None,
468
+ type: str | None = None,
469
+ *,
470
+ key: str | None = None,
471
+ interactive: bool | None = None,
472
+ ) -> Path:
473
+ """Create a new Python project with your defaults.
474
+
475
+ Args:
476
+ name: Project name. Asked for when interactive.
477
+ location: The project folder to create. Asked for when interactive.
478
+ type: project, project_sensitive, course, presentation, or bare. When
479
+ left out, it is asked for interactively and is ``bare`` otherwise,
480
+ as in R's ``new()``.
481
+ key: A Framework Cloud project token. The project's definition (its
482
+ layers) is fetched from the cloud, and the token is saved in the
483
+ new project's ``.env`` for publishing and the ledger.
484
+ interactive: Whether to prompt. Default: whether stdin is a terminal.
485
+
486
+ Returns:
487
+ The project folder.
488
+ """
489
+ interactive = sys.stdin.isatty() if interactive is None else interactive
490
+ if key is not None:
491
+ return _new_from_cloud(key, location, interactive)
492
+ if name is None:
493
+ if not interactive:
494
+ raise FrameworkError("Project name is required in non-interactive mode")
495
+ name = input("Project name: ").strip()
496
+ if not name:
497
+ raise FrameworkError("Project name cannot be empty")
498
+ if location is None:
499
+ if not interactive:
500
+ raise FrameworkError("Project location is required in non-interactive mode")
501
+ suggested = Path("~") / "projects" / kebab_case(name)
502
+ location = input(f"Project location [{suggested}]: ").strip() or suggested
503
+ if type is None:
504
+ if interactive:
505
+ choices = ", ".join(TYPES)
506
+ type = input(f"Project type ({choices}) [bare]: ").strip() or "bare"
507
+ else:
508
+ type = "bare"
509
+ if type not in TYPES:
510
+ raise FrameworkError(f"Invalid project type. Must be one of: {', '.join(TYPES)}")
511
+
512
+ layers: Sequence[Any] = [
513
+ {"kind": "user", "layer": user_layer(type)},
514
+ {"kind": "project", "layer": {"type": type, "language": "python"}},
515
+ ]
516
+ spec = spec_resolve(layers, name).spec
517
+ spec["source"] = {"origin": "python"}
518
+ inform(f"Creating {type} project: {name}")
519
+ root = project_create(spec, location)
520
+ if type != "bare":
521
+ from framework.cloud import register_project
522
+
523
+ register_project(root, name, type)
524
+ return root
525
+
526
+
527
+ def _new_from_cloud(key: str, location: str | Path | None, interactive: bool) -> Path:
528
+ from framework.cloud import fetch_layers, write_project_token
529
+
530
+ envelope, server = fetch_layers(key)
531
+ name = envelope.get("name")
532
+ if not name:
533
+ raise FrameworkError("The cloud project has no name")
534
+ if location is None:
535
+ suggested = Path("~") / "projects" / kebab_case(str(name))
536
+ answer = input(f"Location [{suggested}]: ").strip() if interactive else ""
537
+ location = answer or suggested
538
+ resolved = spec_resolve(envelope.get("layers") or [], str(name), envelope.get("author"))
539
+ spec = resolved.spec
540
+ spec["source"] = envelope.get("source") or {"origin": "cloud"}
541
+ root = project_create(spec, location)
542
+ if spec["type"] != "bare":
543
+ write_project_token(root, key, server)
544
+ inform(f"Project ready at {root}")
545
+ return root
546
+
547
+
548
+ def project_add_directory(project_path: str | Path, key: str, label: str, path: str) -> Path:
549
+ """Create a folder in a project and record it under ``extra_directories``.
550
+
551
+ Args:
552
+ project_path: The project root.
553
+ key: A short key for the folder, such as ``interviews``.
554
+ label: A human-readable name.
555
+ path: The folder, relative to the project root.
556
+
557
+ Returns:
558
+ The created folder.
559
+ """
560
+ from framework.spec.check import is_safe_path
561
+
562
+ root = Path(project_path)
563
+ if not is_safe_path(path):
564
+ raise FrameworkError(f"Path must be relative and stay inside the project: {path}")
565
+ settings_file = next(
566
+ (root / name for name in ("settings.yml", "config.yml") if (root / name).is_file()), None
567
+ )
568
+ if settings_file is None:
569
+ raise FrameworkError("Project config not found (checked settings.yml and config.yml)")
570
+ folder = root / path
571
+ folder.mkdir(parents=True, exist_ok=True)
572
+
573
+ raw = _yaml.read(settings_file) or {}
574
+ config = raw["default"] if isinstance(raw.get("default"), dict) else raw
575
+ extra = [e for e in (config.get("extra_directories") or []) if e.get("key") != key]
576
+ extra.append({"key": key, "label": label, "path": path})
577
+ config["extra_directories"] = extra
578
+ _yaml.write(settings_file, raw)
579
+ return folder