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