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/notebooks.py ADDED
@@ -0,0 +1,384 @@
1
+ """New notebooks and scripts from stubs: ``make_notebook()`` and friends.
2
+
3
+ A stub is a template file named ``notebook-<stub>.<ext>`` or
4
+ ``script-<stub>.py``. Framework looks in the project's ``stubs/`` folder
5
+ first, then in the stubs it ships. ``stubs_publish()`` copies the shipped
6
+ stubs into ``stubs/`` so you can edit them.
7
+
8
+ A template saved with ``write_framework_template()`` comes between the two.
9
+
10
+ Stubs can use these placeholders: ``{{ title }}``, ``{{ filename }}``,
11
+ ``{{ date }}``, ``{{ author }}``, ``{{ email }}``, and ``{{ affiliation }}``.
12
+ In a Quarto stub, an ``author: "{{ author }}"`` line becomes a full author
13
+ block when the author has an ORCID, URL, or affiliations, as in R.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import shutil
21
+ from datetime import date
22
+ from importlib.resources import as_file, files
23
+ from pathlib import Path
24
+ from typing import Any, Literal
25
+
26
+ import pandas as pd
27
+
28
+ from framework._log import inform, show_messages
29
+ from framework.config_home import global_settings
30
+ from framework.errors import FrameworkError
31
+ from framework.global_config import templates_dir
32
+ from framework.project import find_settings_file
33
+ from framework.settings import settings_read
34
+ from framework.spec.resolve import author_merge, author_normalize
35
+
36
+ NotebookType = Literal["quarto", "jupyter", "script"]
37
+ _EXTENSIONS: dict[NotebookType, str] = {"quarto": "qmd", "jupyter": "ipynb", "script": "py"}
38
+ _TYPES: dict[str, NotebookType] = {ext: kind for kind, ext in _EXTENSIONS.items()}
39
+ _STUB_FILE = re.compile(r"^(notebook|script)-(.+)\.(qmd|ipynb|py)$")
40
+ _PLACEHOLDERS = {
41
+ "name": "Your Name",
42
+ "email": "your.email@example.com",
43
+ "affiliation": "Your Institution",
44
+ }
45
+ _AFFILIATION_FIELDS = ("name", "department", "address", "city", "country", "url")
46
+
47
+
48
+ def _shipped_stubs() -> Path:
49
+ with as_file(files("framework").joinpath("templates/stubs")) as path:
50
+ return Path(path)
51
+
52
+
53
+ def _project() -> tuple[Path, dict[str, Any]]:
54
+ """The project root and settings, or the current folder and no settings."""
55
+ found = find_settings_file()
56
+ if found is None:
57
+ return Path.cwd(), {}
58
+ try:
59
+ return found.parent, settings_read(found)
60
+ except FrameworkError:
61
+ return found.parent, {}
62
+
63
+
64
+ def slugify(text: str) -> str:
65
+ """Lowercase letters, digits, and hyphens, as R names notebook files."""
66
+ slug = re.sub(r"[^a-z0-9-]", "-", text.lower())
67
+ return re.sub(r"-+", "-", slug).strip("-")
68
+
69
+
70
+ def _section(config: dict[str, Any], name: str) -> dict[str, Any]:
71
+ value = config.get(name)
72
+ return value if isinstance(value, dict) else {}
73
+
74
+
75
+ # Authors ----------------------------------------------------------------------
76
+
77
+
78
+ def _author(config: dict[str, Any]) -> dict[str, Any]:
79
+ """The project author over the global author, without the default placeholders."""
80
+ global_author = dict(_section(global_settings(), "author"))
81
+ for key, placeholder in _PLACEHOLDERS.items():
82
+ if global_author.get(key) == placeholder:
83
+ global_author[key] = ""
84
+ return author_merge(global_author, _section(config, "author"))
85
+
86
+
87
+ def _yaml_quote(value: str) -> str:
88
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
89
+
90
+
91
+ def author_front_matter(author: dict[str, Any], default_name: str = "Your Name") -> list[str]:
92
+ """Quarto front matter lines for an author, matching R's ``.author_front_matter()``."""
93
+ author = author_normalize(author)
94
+ name = author["name"] or default_name
95
+ if not any(key in author for key in ("orcid", "url", "affiliations")):
96
+ return [f"author: {_yaml_quote(name)}"]
97
+ lines = ["author:", f" - name: {_yaml_quote(name)}"]
98
+ for key in ("email", "orcid", "url"):
99
+ if author.get(key):
100
+ lines.append(f" {key}: {_yaml_quote(author[key])}")
101
+ affiliations = author.get("affiliations") or (
102
+ [{"name": author["affiliation"]}] if author.get("affiliation") else []
103
+ )
104
+ if affiliations:
105
+ lines.append(" affiliations:")
106
+ for affiliation in affiliations:
107
+ lines.append(f" - name: {_yaml_quote(affiliation['name'])}")
108
+ for key in _AFFILIATION_FIELDS[1:]:
109
+ if affiliation.get(key):
110
+ lines.append(f" {key}: {_yaml_quote(affiliation[key])}")
111
+ return lines
112
+
113
+
114
+ def _fill(content: str, ext: str, values: dict[str, str], author: dict[str, Any]) -> str:
115
+ if ext == "qmd":
116
+ content = re.sub(
117
+ r'(?m)^author:\s*"\{\{\s*author\s*\}\}"\s*$',
118
+ lambda _: "\n".join(author_front_matter(author)),
119
+ content,
120
+ count=1,
121
+ )
122
+ for name, value in values.items():
123
+ # Notebook files are JSON, so values go in as JSON string contents.
124
+ text = json.dumps(value)[1:-1] if ext == "ipynb" else value
125
+ # Escape backslashes: re.sub would read them as group references.
126
+ content = re.sub(r"\{\{\s*" + name + r"\s*\}\}", text.replace("\\", "\\\\"), content)
127
+ content = content.replace("{" + name + "}", text)
128
+ return content
129
+
130
+
131
+ # Stubs ------------------------------------------------------------------------
132
+
133
+
134
+ def _find_stub(root: Path, prefix: str, stub: str, ext: str) -> Path:
135
+ filename = f"{prefix}-{stub}.{ext}"
136
+ user = root / "stubs" / filename
137
+ if user.is_file():
138
+ inform(f"Using user stub: {user}")
139
+ return user
140
+ personal = templates_dir() / filename
141
+ if personal.is_file():
142
+ return personal
143
+ shipped = _shipped_stubs() / filename
144
+ if shipped.is_file():
145
+ return shipped
146
+ available = sorted(stubs_list(_TYPES[ext])["name"])
147
+ raise FrameworkError(
148
+ f"Stub template not found: {stub}\n\nSearched:\n - {user}\n - Framework's {filename}\n\n"
149
+ f"Available stubs: {', '.join(available) or 'none'}"
150
+ )
151
+
152
+
153
+ def _normalize(name: str, kind: NotebookType) -> tuple[str, NotebookType, str]:
154
+ """The file name, notebook type, and extension; an extension in ``name`` wins."""
155
+ match = re.search(r"\.(qmd|ipynb|py)$", name, re.IGNORECASE)
156
+ if match:
157
+ ext = match.group(1).lower()
158
+ kind = _TYPES[ext]
159
+ name = name[: match.start()]
160
+ ext = _EXTENSIONS[kind]
161
+ slug = slugify(name)
162
+ if not slug:
163
+ raise ValueError(f"Cannot make a file name from {name!r}")
164
+ return f"{slug}.{ext}", kind, ext
165
+
166
+
167
+ def _default_dir(config: dict[str, Any], root: Path, keys: tuple[str, ...], option: str) -> Path:
168
+ directories = _section(config, "directories")
169
+ for key in keys:
170
+ if isinstance(directories.get(key), str) and directories[key]:
171
+ return root / str(directories[key])
172
+ options = _section(config, "options")
173
+ if isinstance(options.get(option), str) and options[option]:
174
+ return root / str(options[option])
175
+ for fallback in keys:
176
+ if (root / fallback).is_dir():
177
+ return root / fallback
178
+ return root
179
+
180
+
181
+ def _default_type(config: dict[str, Any]) -> NotebookType:
182
+ value = (
183
+ _section(config, "scaffold").get("notebook_format")
184
+ or _section(config, "options").get("default_notebook_format")
185
+ or config.get("default_notebook_format")
186
+ )
187
+ if value in ("jupyter", "ipynb"):
188
+ return "jupyter"
189
+ return "quarto"
190
+
191
+
192
+ # Public API -------------------------------------------------------------------
193
+
194
+
195
+ def make_notebook(
196
+ name: str,
197
+ type: NotebookType | None = None,
198
+ dir: str | Path | None = None,
199
+ stub: str = "default",
200
+ *,
201
+ overwrite: bool = False,
202
+ subdir: str | None = None,
203
+ ) -> Path:
204
+ """Create a notebook from a stub.
205
+
206
+ Args:
207
+ name: The notebook name. It is turned into a file name such as
208
+ ``data-cleaning.qmd``. An extension (``.qmd``, ``.ipynb``, or
209
+ ``.py``) sets the type.
210
+ type: ``"quarto"`` (the default), ``"jupyter"``, or ``"script"``.
211
+ Without it, ``scaffold: notebook_format`` in the settings decides.
212
+ dir: The folder. Defaults to ``directories: notebooks`` in the
213
+ settings, relative to the project root.
214
+ stub: The stub to start from, such as ``"default"`` or ``"minimal"``.
215
+ overwrite: Replace an existing file.
216
+ subdir: A subfolder of ``dir``.
217
+
218
+ Returns:
219
+ The path of the new file.
220
+ """
221
+ show_messages()
222
+ root, config = _project()
223
+ if type is not None and type not in _EXTENSIONS:
224
+ raise ValueError("type must be 'quarto', 'jupyter', or 'script'")
225
+ filename, kind, ext = _normalize(name, type or _default_type(config))
226
+ if dir is None:
227
+ folder = (
228
+ _default_dir(config, root, ("scripts",), "script_dir")
229
+ if kind == "script"
230
+ else _default_dir(config, root, ("notebooks", "work"), "notebook_dir")
231
+ )
232
+ else:
233
+ folder = Path(dir)
234
+ if subdir:
235
+ folder = folder / subdir
236
+ if not folder.exists():
237
+ folder.mkdir(parents=True)
238
+ inform(f"Created directory: {folder}")
239
+ target = folder / filename
240
+ if target.exists() and not overwrite:
241
+ raise FrameworkError(f"File already exists: {target}\nUse overwrite=True to replace it.")
242
+
243
+ source = _find_stub(root, "script" if kind == "script" else "notebook", stub, ext)
244
+ author = _author(config)
245
+ title = re.sub(r"\.(qmd|ipynb|py)$", "", Path(name).name, flags=re.IGNORECASE)
246
+ values = {
247
+ "title": title,
248
+ "filename": title,
249
+ "date": f"{date.today():%Y-%m-%d}",
250
+ "author": author.get("name") or "Your Name",
251
+ "email": author.get("email", ""),
252
+ "affiliation": author.get("affiliation", ""),
253
+ }
254
+ content = _fill(source.read_text(encoding="utf-8"), ext, values, author)
255
+ target.write_text(content, encoding="utf-8")
256
+ label = {"quarto": "Quarto notebook", "jupyter": "Jupyter notebook", "script": "Python script"}
257
+ inform(f"Created {label[kind]}: {target}")
258
+ if stub != "default":
259
+ inform(f" Using stub: {stub}")
260
+ return target
261
+
262
+
263
+ def make_qmd(
264
+ name: str,
265
+ dir: str | Path | None = None,
266
+ stub: str = "default",
267
+ *,
268
+ overwrite: bool = False,
269
+ subdir: str | None = None,
270
+ ) -> Path:
271
+ """Create a Quarto notebook (``.qmd``) with Python code chunks."""
272
+ return make_notebook(name, "quarto", dir, stub, overwrite=overwrite, subdir=subdir)
273
+
274
+
275
+ def make_revealjs(
276
+ name: str, dir: str | Path | None = None, *, overwrite: bool = False, subdir: str | None = None
277
+ ) -> Path:
278
+ """Create a Quarto reveal.js presentation."""
279
+ return make_notebook(name, "quarto", dir, "revealjs", overwrite=overwrite, subdir=subdir)
280
+
281
+
282
+ def make_presentation(
283
+ name: str, dir: str | Path | None = None, *, overwrite: bool = False, subdir: str | None = None
284
+ ) -> Path:
285
+ """Create a presentation. The same as ``make_revealjs()``."""
286
+ return make_revealjs(name, dir, overwrite=overwrite, subdir=subdir)
287
+
288
+
289
+ def make_script(
290
+ name: str, dir: str | Path | None = None, stub: str = "default", *, overwrite: bool = False
291
+ ) -> Path:
292
+ """Create a Python script (``.py``) in the scripts folder."""
293
+ if not name.lower().endswith(".py"):
294
+ name = f"{name}.py"
295
+ return make_notebook(name, "script", dir, stub, overwrite=overwrite)
296
+
297
+
298
+ def stubs_list(type: NotebookType | None = None) -> pd.DataFrame:
299
+ """The available stubs: the project's own, then Framework's.
300
+
301
+ Returns:
302
+ A DataFrame with columns name, type, and source (``user`` or
303
+ ``framework``). A project stub hides Framework's of the same name.
304
+ """
305
+ if type is not None and type not in _EXTENSIONS:
306
+ raise ValueError("type must be 'quarto', 'jupyter', or 'script'")
307
+ root, _ = _project()
308
+ rows = []
309
+ for source, folder in (("user", root / "stubs"), ("framework", _shipped_stubs())):
310
+ if not folder.is_dir():
311
+ continue
312
+ for path in sorted(folder.iterdir()):
313
+ match = _STUB_FILE.match(path.name)
314
+ if match:
315
+ rows.append(
316
+ {"name": match.group(2), "type": _TYPES[match.group(3)], "source": source}
317
+ )
318
+ frame = pd.DataFrame(rows, columns=["name", "type", "source"])
319
+ if type is not None:
320
+ frame = frame[frame["type"] == type]
321
+ frame = frame.drop_duplicates(["name", "type"]).sort_values(["name", "type"])
322
+ return frame.reset_index(drop=True)
323
+
324
+
325
+ def stubs_path(which: Literal["auto", "user", "framework"] = "auto") -> Path:
326
+ """Where stubs are read from: the project's ``stubs/`` folder or Framework's.
327
+
328
+ ``"auto"`` gives the project folder when it exists.
329
+ """
330
+ if which not in ("auto", "user", "framework"):
331
+ raise ValueError("which must be 'auto', 'user', or 'framework'")
332
+ root, _ = _project()
333
+ user = root / "stubs"
334
+ if which == "user" or (which == "auto" and user.is_dir()):
335
+ return user
336
+ return _shipped_stubs()
337
+
338
+
339
+ def stubs_publish(
340
+ type: str | list[str] = "all", *, overwrite: bool = False, stubs: list[str] | None = None
341
+ ) -> list[Path]:
342
+ """Copy Framework's stubs into the project's ``stubs/`` folder to customize them.
343
+
344
+ Args:
345
+ type: ``"all"``, ``"notebooks"``, or ``"scripts"``, or a list of them.
346
+ overwrite: Replace stubs already in ``stubs/``.
347
+ stubs: Only these stub names, such as ``["default"]``.
348
+
349
+ Returns:
350
+ The files copied.
351
+ """
352
+ show_messages()
353
+ kinds = {type} if isinstance(type, str) else set(type)
354
+ if not kinds <= {"all", "notebooks", "scripts"}:
355
+ raise ValueError("type must be 'all', 'notebooks', or 'scripts'")
356
+ if "all" in kinds:
357
+ kinds = {"notebooks", "scripts"}
358
+ root, _ = _project()
359
+ target_dir = root / "stubs"
360
+ if not target_dir.exists():
361
+ target_dir.mkdir(parents=True)
362
+ inform(f"Created directory: {target_dir}/")
363
+ published: list[Path] = []
364
+ for source in sorted(_shipped_stubs().iterdir()):
365
+ match = _STUB_FILE.match(source.name)
366
+ if match is None:
367
+ continue
368
+ if f"{match.group(1)}s" not in kinds:
369
+ continue
370
+ if stubs is not None and match.group(2) not in stubs:
371
+ continue
372
+ target = target_dir / source.name
373
+ if target.exists() and not overwrite:
374
+ inform(f"Skipped (exists): {target}")
375
+ continue
376
+ shutil.copyfile(source, target)
377
+ inform(f"Published: {target}")
378
+ published.append(target)
379
+ if published:
380
+ inform(f"Published {len(published)} stub(s) to {target_dir}/")
381
+ inform(" Edit these files to customize your templates.")
382
+ else:
383
+ inform("No stubs were published.")
384
+ return published