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