simcon-toolkit 0.1.0__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.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Assemble one template into a standalone project folder.
|
|
2
|
+
|
|
3
|
+
A template cannot run inside this repository: its code reads `sample/<part>.stl`, a path
|
|
4
|
+
that deliberately resolves only after generation. So generating is the only thing that
|
|
5
|
+
produces a runnable project, and the only thing that can prove this code works.
|
|
6
|
+
|
|
7
|
+
Shared code arrives as the packages it already is, under its own name. Flattening it into
|
|
8
|
+
the workflow folder was tried and reversed: a bare `stl.py` beside the customer's script
|
|
9
|
+
shadows numpy-stl, the standard STL library in this industry, and their own
|
|
10
|
+
`from stl import mesh` then fails naming a library they never installed.
|
|
11
|
+
|
|
12
|
+
A folder that already holds files is reconciled rather than replaced, the way copier,
|
|
13
|
+
`dotnet new` and Rails generators behave: new files are added, identical ones left alone,
|
|
14
|
+
and a file that would change is a clash that stops the run before anything is written.
|
|
15
|
+
`force` overwrites exactly the clashes. Nothing the template does not write is ever
|
|
16
|
+
deleted — an earlier version replaced the whole folder and took the customer's `.git` and
|
|
17
|
+
their own notes with it, which no mainstream scaffolder does.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import errno
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import tempfile
|
|
28
|
+
import unicodedata
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from string import Template
|
|
32
|
+
|
|
33
|
+
from simcon_toolkit.choices import DEFAULT_PACKAGE_MANAGER, PACKAGE_MANAGERS
|
|
34
|
+
from simcon_toolkit.instructions import SHARED_BASE, write_instruction_files
|
|
35
|
+
from simcon_toolkit.manifest import ROOT, TemplateSpec, load_template
|
|
36
|
+
from simcon_toolkit.project import (
|
|
37
|
+
GITATTRIBUTES,
|
|
38
|
+
PYTHON_VERSION_PIN,
|
|
39
|
+
UV_SETUP,
|
|
40
|
+
content_digest,
|
|
41
|
+
is_excluded,
|
|
42
|
+
render_pyproject,
|
|
43
|
+
stamp,
|
|
44
|
+
write_sample,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
#: Which template and version a project came from. Named after the tool, like
|
|
48
|
+
#: `.copier-answers.yml`; the hosted instructions tell an assistant to look for it.
|
|
49
|
+
STAMP = ".simcon-toolkit.json"
|
|
50
|
+
|
|
51
|
+
#: Around the setup block of every template README. The block between them is the pip route,
|
|
52
|
+
#: which is what a reader of this repository sees; a uv project gets `UV_SETUP` in its place.
|
|
53
|
+
#: HTML comments, so they render as nothing, and removed from every project either way.
|
|
54
|
+
SETUP_START = "<!-- simcon-toolkit:setup -->"
|
|
55
|
+
SETUP_END = "<!-- /simcon-toolkit:setup -->"
|
|
56
|
+
|
|
57
|
+
#: The staging folder's name, so the walk that reports kept files can never mistake one
|
|
58
|
+
#: left behind by a killed run for the customer's own work.
|
|
59
|
+
STAGING_PREFIX = ".simcon-toolkit-"
|
|
60
|
+
|
|
61
|
+
#: Folders never walked when reporting what was kept: version control, environments and
|
|
62
|
+
#: caches, each of which holds hundreds of files nobody wants listed back to them.
|
|
63
|
+
PRUNED = frozenset(
|
|
64
|
+
{".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache", "node_modules"}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: Files an operating system writes on its own, and not worth naming.
|
|
68
|
+
NOISE = frozenset({".DS_Store", "Thumbs.db"})
|
|
69
|
+
|
|
70
|
+
# Exclusion rules live in simcon_toolkit.project, beside the digest that must apply the same ones.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: Where a placeholder may appear. Every text file a template ships, NOT just its README:
|
|
74
|
+
#: a placeholder left unsubstituted ships verbatim into a customer's project and nothing
|
|
75
|
+
#: downstream would ever notice it. A `.py` file is deliberately absent — a placeholder
|
|
76
|
+
#: inside one would make the file invalid Python and invisible to lint and to the tests.
|
|
77
|
+
RENDERED_SUFFIXES = (".md", ".toml", ".json", ".yaml", ".yml", ".txt")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PlaceholderTemplate(Template):
|
|
81
|
+
"""`string.Template` with `%%` as the delimiter.
|
|
82
|
+
|
|
83
|
+
`$` collides with `$PY` and `$ pip` already written in our own documentation, and a
|
|
84
|
+
prompt character in a shell example is not a placeholder.
|
|
85
|
+
|
|
86
|
+
The form is `%%{name}` or bare `%%name` — a *leading* delimiter only. `%%name%%` is
|
|
87
|
+
not the syntax and raises, because the trailing pair is itself an invalid placeholder.
|
|
88
|
+
Write the braced form, which stays unambiguous next to other text.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
delimiter = "%%"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _swap_setup(readme: Path, package_manager: str) -> None:
|
|
95
|
+
"""Give the README the setup block for the installer the customer chose, and drop the markers.
|
|
96
|
+
|
|
97
|
+
A README without exactly one pair is refused rather than shipped: the pip route would
|
|
98
|
+
reach a customer who chose uv, and nothing downstream would notice.
|
|
99
|
+
"""
|
|
100
|
+
text = readme.read_text(encoding="utf-8")
|
|
101
|
+
if text.count(SETUP_START) != 1 or text.count(SETUP_END) != 1 or text.index(SETUP_START) > text.index(SETUP_END):
|
|
102
|
+
raise GeneratorError(f"{readme.name} must mark its setup block once, with {SETUP_START} and {SETUP_END}.")
|
|
103
|
+
before, rest = text.split(SETUP_START)
|
|
104
|
+
kept, after = rest.split(SETUP_END)
|
|
105
|
+
# The markers sit on their own lines; take the newline after each with it.
|
|
106
|
+
kept, after = kept.removeprefix("\n"), after.removeprefix("\n")
|
|
107
|
+
block = UV_SETUP if package_manager == "uv" else kept
|
|
108
|
+
readme.write_text(before + block + after, encoding="utf-8", newline="\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _render_in_place(path: Path, values: dict[str, str]) -> None:
|
|
112
|
+
text = path.read_text(encoding="utf-8")
|
|
113
|
+
if PlaceholderTemplate.delimiter not in text:
|
|
114
|
+
return
|
|
115
|
+
# Strict: an unknown placeholder raises rather than shipping a literal `%%name%%` into
|
|
116
|
+
# a customer's project, where nothing downstream would ever notice it.
|
|
117
|
+
path.write_text(PlaceholderTemplate(text).substitute(values), encoding="utf-8", newline="\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Generated:
|
|
122
|
+
"""What one run did to the destination, each list relative to it and sorted.
|
|
123
|
+
|
|
124
|
+
Split four ways because each one means something different to the person reading the
|
|
125
|
+
report: new work, work of theirs we replaced, nothing to do, and files we left alone.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
destination: Path
|
|
129
|
+
created: list[str]
|
|
130
|
+
overwritten: list[str]
|
|
131
|
+
unchanged: list[str]
|
|
132
|
+
kept: list[str]
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def written(self) -> list[str]:
|
|
136
|
+
"""Every file this run put on disk."""
|
|
137
|
+
return sorted(self.created + self.overwritten)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def generate_project(
|
|
141
|
+
template: str,
|
|
142
|
+
destination: Path,
|
|
143
|
+
*,
|
|
144
|
+
project_name: str | None = None,
|
|
145
|
+
force: bool = False,
|
|
146
|
+
generated_at: str | None = None,
|
|
147
|
+
cli_version: str | None = None,
|
|
148
|
+
package_manager: str = DEFAULT_PACKAGE_MANAGER,
|
|
149
|
+
) -> Generated:
|
|
150
|
+
"""Write one template into `destination`, reconciling with whatever is already there.
|
|
151
|
+
|
|
152
|
+
A folder that already holds files is fine as long as none of them is a file the template
|
|
153
|
+
would write with different content. Every such clash is found before anything is
|
|
154
|
+
written, so a refusal changes nothing; `force` overwrites exactly those files. Nothing
|
|
155
|
+
the template does not write is ever deleted, and `.git` is never looked at.
|
|
156
|
+
"""
|
|
157
|
+
if package_manager not in PACKAGE_MANAGERS:
|
|
158
|
+
raise GeneratorError(f"no package manager named {package_manager!r}. Available: {', '.join(PACKAGE_MANAGERS)}.")
|
|
159
|
+
spec: TemplateSpec = load_template(template)
|
|
160
|
+
shown = Path(destination)
|
|
161
|
+
# Absolute before anything else: `Path(".").parent` is `.` itself, so staging "beside"
|
|
162
|
+
# a relative `.` put it inside the project and reported our own staging files as the
|
|
163
|
+
# customer's.
|
|
164
|
+
# The names are checked as TYPED, before anything normalises them: on Windows `abspath`
|
|
165
|
+
# silently drops a trailing dot or space, and a device name such as `nul` "exists".
|
|
166
|
+
check_new_path(shown)
|
|
167
|
+
destination = Path(os.path.abspath(shown))
|
|
168
|
+
if destination.exists() and not destination.is_dir():
|
|
169
|
+
raise GeneratorError(f"{shown} is a file, not a folder. Choose another name.")
|
|
170
|
+
name = project_name or distribution_name(destination.name)
|
|
171
|
+
check_project_name(name)
|
|
172
|
+
|
|
173
|
+
staging = _staging_directory(destination)
|
|
174
|
+
try:
|
|
175
|
+
workspace = staging / "project"
|
|
176
|
+
_assemble(
|
|
177
|
+
spec,
|
|
178
|
+
workspace,
|
|
179
|
+
name,
|
|
180
|
+
generated_at=generated_at,
|
|
181
|
+
cli_version=cli_version,
|
|
182
|
+
package_manager=package_manager,
|
|
183
|
+
)
|
|
184
|
+
return _reconcile(workspace, destination, shown, force=force)
|
|
185
|
+
finally:
|
|
186
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _staging_directory(destination: Path) -> Path:
|
|
190
|
+
"""Where the project is assembled before it is compared and moved into place.
|
|
191
|
+
|
|
192
|
+
Beside the destination, so each final move is a rename within one filesystem: on Linux
|
|
193
|
+
the system temp directory is frequently a different one, and a cross-device move is a
|
|
194
|
+
copy. When the parent refuses a write — generating into a home directory, whose parent
|
|
195
|
+
belongs to the system — the temp directory is the fallback, at the cost of a copy.
|
|
196
|
+
"""
|
|
197
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
try:
|
|
199
|
+
return Path(tempfile.mkdtemp(prefix=STAGING_PREFIX, dir=destination.parent))
|
|
200
|
+
except PermissionError:
|
|
201
|
+
return Path(tempfile.mkdtemp(prefix=STAGING_PREFIX))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assemble(
|
|
205
|
+
spec: TemplateSpec,
|
|
206
|
+
workspace: Path,
|
|
207
|
+
name: str,
|
|
208
|
+
*,
|
|
209
|
+
generated_at: str | None,
|
|
210
|
+
cli_version: str | None,
|
|
211
|
+
package_manager: str,
|
|
212
|
+
) -> None:
|
|
213
|
+
_copy_tree(spec.directory, workspace)
|
|
214
|
+
_swap_setup(workspace / "README.md", package_manager)
|
|
215
|
+
for module in spec.module_sources():
|
|
216
|
+
_copy_tree(module, workspace / module.name)
|
|
217
|
+
|
|
218
|
+
write_sample(workspace, spec)
|
|
219
|
+
|
|
220
|
+
# Overwrites the template's own half, which the copy above brought across under the
|
|
221
|
+
# same name. The half is an input to the join, never the file a customer receives.
|
|
222
|
+
write_instruction_files(workspace, spec.directory, package_manager=package_manager)
|
|
223
|
+
|
|
224
|
+
(workspace / "pyproject.toml").write_text(render_pyproject(spec, name), encoding="utf-8", newline="\n")
|
|
225
|
+
(workspace / ".python-version").write_text(PYTHON_VERSION_PIN + "\n", encoding="utf-8", newline="\n")
|
|
226
|
+
(workspace / ".gitattributes").write_text(GITATTRIBUTES, encoding="utf-8", newline="\n")
|
|
227
|
+
# Through _copy_file, not write_bytes: these are text and must be normalised like
|
|
228
|
+
# every other copied file, or a Windows checkout ships them as the only CRLF files
|
|
229
|
+
# in an otherwise LF project.
|
|
230
|
+
_copy_file(ROOT / "LICENSE", workspace / "LICENSE")
|
|
231
|
+
_copy_file(ROOT / "NOTICE", workspace / "NOTICE")
|
|
232
|
+
|
|
233
|
+
digest = content_digest([SHARED_BASE, spec.directory, *spec.module_sources()])
|
|
234
|
+
record = stamp(spec, digest, generated_at=generated_at, cli_version=cli_version, package_manager=package_manager)
|
|
235
|
+
(workspace / STAMP).write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
236
|
+
|
|
237
|
+
values = {"project_name": name, "template": spec.name, "sample": spec.sample}
|
|
238
|
+
for rendered in sorted(p for p in workspace.rglob("*") if p.is_file() and p.suffix in RENDERED_SUFFIXES):
|
|
239
|
+
_render_in_place(rendered, values)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _reconcile(workspace: Path, destination: Path, shown: Path, *, force: bool) -> Generated:
|
|
243
|
+
ours = sorted(p.relative_to(workspace).as_posix() for p in workspace.rglob("*") if p.is_file())
|
|
244
|
+
created, clashes, unchanged, blocked = [], [], [], []
|
|
245
|
+
# The on-disk spelling of each clash, which differs from ours only on a case-insensitive
|
|
246
|
+
# filesystem: `readme.md` IS `README.md` on macOS and Windows.
|
|
247
|
+
replaced_on_disk: dict[str, str] = {}
|
|
248
|
+
for relative in ours:
|
|
249
|
+
if _something_in_the_way(destination, relative):
|
|
250
|
+
blocked.append(relative)
|
|
251
|
+
continue
|
|
252
|
+
target = destination / relative
|
|
253
|
+
on_disk = _on_disk_spelling(destination, relative)
|
|
254
|
+
if on_disk is None and not target.is_symlink():
|
|
255
|
+
created.append(relative)
|
|
256
|
+
continue
|
|
257
|
+
# The stamp is compared by presence, not by content: two runs inside one second
|
|
258
|
+
# write identical stamps, and an existing project must always be named as one.
|
|
259
|
+
identical = (
|
|
260
|
+
on_disk == relative
|
|
261
|
+
and relative != STAMP
|
|
262
|
+
and target.is_file()
|
|
263
|
+
and not target.is_symlink()
|
|
264
|
+
and target.read_bytes() == (workspace / relative).read_bytes()
|
|
265
|
+
)
|
|
266
|
+
if identical:
|
|
267
|
+
unchanged.append(relative)
|
|
268
|
+
else:
|
|
269
|
+
clashes.append(relative)
|
|
270
|
+
replaced_on_disk[relative] = on_disk or relative
|
|
271
|
+
|
|
272
|
+
if blocked:
|
|
273
|
+
raise GeneratorError(
|
|
274
|
+
f"{shown} holds something in the way that is not a file this template writes: "
|
|
275
|
+
f"{format_listing(blocked)}. A folder, a link or a differently-capitalised folder sits where "
|
|
276
|
+
"the template writes. Move it aside, or generate into a new folder."
|
|
277
|
+
)
|
|
278
|
+
if clashes and not force:
|
|
279
|
+
raise ConflictError(shown, clashes, replaced_on_disk)
|
|
280
|
+
|
|
281
|
+
kept = _kept(destination, set(ours) | set(replaced_on_disk.values()))
|
|
282
|
+
existed = destination.exists()
|
|
283
|
+
moved: list[str] = []
|
|
284
|
+
try:
|
|
285
|
+
for relative in created + clashes:
|
|
286
|
+
target = destination / relative
|
|
287
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
spelled = replaced_on_disk.get(relative, relative)
|
|
289
|
+
if spelled != relative:
|
|
290
|
+
# Replacing through the old spelling would keep it, so the customer's copy
|
|
291
|
+
# keeps a name the project's own `pyproject.toml` does not point at on Linux.
|
|
292
|
+
(destination / spelled).unlink()
|
|
293
|
+
_move(workspace / relative, target)
|
|
294
|
+
moved.append(relative)
|
|
295
|
+
except OSError as failure:
|
|
296
|
+
# Only a folder this run created is ours to remove; one that was there before holds
|
|
297
|
+
# the customer's files, so it is left as it is and the error says what was written.
|
|
298
|
+
if not existed:
|
|
299
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
300
|
+
raise
|
|
301
|
+
raise WriteFailed(
|
|
302
|
+
f"writing into {shown} stopped after {len(moved)} of {len(created) + len(clashes)} files "
|
|
303
|
+
f"({failure.strerror or failure}). Nothing already there was removed. Fix the cause and "
|
|
304
|
+
"run the same command with --force to finish."
|
|
305
|
+
) from failure
|
|
306
|
+
return Generated(shown, created, clashes, unchanged, kept)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _move(source: Path, target: Path) -> None:
|
|
310
|
+
"""A rename, or a copy when staging had to fall back to another filesystem."""
|
|
311
|
+
try:
|
|
312
|
+
os.replace(source, target)
|
|
313
|
+
except OSError as failure:
|
|
314
|
+
if failure.errno != errno.EXDEV:
|
|
315
|
+
raise
|
|
316
|
+
shutil.copy2(source, target)
|
|
317
|
+
source.unlink()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _something_in_the_way(destination: Path, relative: str) -> bool:
|
|
321
|
+
"""Whether writing `relative` would mean deleting or writing through something.
|
|
322
|
+
|
|
323
|
+
A folder where we write a file, a file where we need a folder, and a LINK anywhere on the
|
|
324
|
+
way: `is_dir()` follows a link, so a linked folder looks like our own and the files land
|
|
325
|
+
wherever it points, outside the project.
|
|
326
|
+
"""
|
|
327
|
+
for parent in _parents(relative):
|
|
328
|
+
path = destination / parent
|
|
329
|
+
if path.is_symlink():
|
|
330
|
+
return True
|
|
331
|
+
spelled = _on_disk_spelling(destination, parent)
|
|
332
|
+
if spelled is not None and (spelled != parent or not path.is_dir()):
|
|
333
|
+
return True
|
|
334
|
+
# Follows a link on purpose: a link to a folder is as much in the way as a folder.
|
|
335
|
+
return (destination / relative).is_dir()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _on_disk_spelling(destination: Path, relative: str) -> str | None:
|
|
339
|
+
"""The path as the filesystem spells it, or None when nothing is there.
|
|
340
|
+
|
|
341
|
+
Asked of the directory listing, because `exists()` answers True for `README.md` when
|
|
342
|
+
the folder holds `readme.md` on a case-insensitive filesystem, and says nothing about
|
|
343
|
+
which name is actually on disk.
|
|
344
|
+
"""
|
|
345
|
+
current = destination
|
|
346
|
+
spelled = []
|
|
347
|
+
for part in relative.split("/"):
|
|
348
|
+
try:
|
|
349
|
+
entries = os.listdir(current)
|
|
350
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
351
|
+
return None
|
|
352
|
+
if part in entries:
|
|
353
|
+
match = part
|
|
354
|
+
else:
|
|
355
|
+
folded = [entry for entry in entries if os.path.normcase(entry).casefold() == part.casefold()]
|
|
356
|
+
if not folded or not (current / part).exists():
|
|
357
|
+
return None
|
|
358
|
+
match = folded[0]
|
|
359
|
+
spelled.append(match)
|
|
360
|
+
current = current / match
|
|
361
|
+
return "/".join(spelled)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _parents(relative: str) -> list[str]:
|
|
365
|
+
parts = relative.split("/")[:-1]
|
|
366
|
+
return ["/".join(parts[: index + 1]) for index in range(len(parts))]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _kept(destination: Path, ours: set[str]) -> list[str]:
|
|
370
|
+
"""Files already in the destination that this template does not write.
|
|
371
|
+
|
|
372
|
+
Reported rather than removed: they may be the customer's own work, or they may be left
|
|
373
|
+
over from a different template, and only the customer can tell which. The walk prunes
|
|
374
|
+
`.git`, virtual environments and caches rather than filtering them afterwards, so a
|
|
375
|
+
project with a `.venv` is not reported file by file and a home directory is not walked.
|
|
376
|
+
"""
|
|
377
|
+
if not destination.is_dir():
|
|
378
|
+
return []
|
|
379
|
+
found = []
|
|
380
|
+
for folder, directories, files in os.walk(destination):
|
|
381
|
+
directories[:] = sorted(
|
|
382
|
+
name
|
|
383
|
+
for name in directories
|
|
384
|
+
if name not in PRUNED
|
|
385
|
+
and not name.startswith(STAGING_PREFIX)
|
|
386
|
+
and not os.path.islink(os.path.join(folder, name))
|
|
387
|
+
)
|
|
388
|
+
for name in sorted(files):
|
|
389
|
+
if name in NOISE:
|
|
390
|
+
continue
|
|
391
|
+
relative = Path(folder, name).relative_to(destination).as_posix()
|
|
392
|
+
if relative not in ours:
|
|
393
|
+
found.append(relative)
|
|
394
|
+
return sorted(found)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def format_listing(paths: list[str], limit: int = 8) -> str:
|
|
398
|
+
"""At most `limit` paths on one line, then a count, so a report never floods a terminal."""
|
|
399
|
+
shown = ", ".join(paths[:limit])
|
|
400
|
+
return shown if len(paths) <= limit else f"{shown} and {len(paths) - limit} more"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
#: Folder names Windows cannot create, whatever follows a dot: `NUL.txt` is `NUL`. The
|
|
404
|
+
#: superscript digits are Microsoft's own addition, and are reserved in every directory.
|
|
405
|
+
RESERVED_DEVICE_NAMES = frozenset(
|
|
406
|
+
# CONIN$ and CONOUT$ are absent from Microsoft's naming page and present in CPython's
|
|
407
|
+
# own reserved-name set, which is the list `os.path.isreserved` answers from.
|
|
408
|
+
{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
|
|
409
|
+
| {f"{port}{digit}" for port in ("COM", "LPT") for digit in (*"123456789", "¹", "²", "³")}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
#: The longest folder name the filesystems a project reaches all accept.
|
|
413
|
+
MAX_FOLDER_NAME_BYTES = 255
|
|
414
|
+
|
|
415
|
+
#: Characters Windows refuses in a name, plus the control range it refuses too.
|
|
416
|
+
RESERVED_CHARACTERS = frozenset('<>:"/\\|?*') | {chr(code) for code in range(32)}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def check_folder_name(name: str) -> None:
|
|
420
|
+
"""Fail on a folder name that cannot exist on every machine a project may reach.
|
|
421
|
+
|
|
422
|
+
Checked on every platform, not only on Windows: a project made on a Mac is committed and
|
|
423
|
+
cloned by a colleague on Windows, and a folder called `aux` then cannot be checked out.
|
|
424
|
+
"""
|
|
425
|
+
if name in ("", ".", ".."):
|
|
426
|
+
raise GeneratorError(f"{name!r} is not a folder name. Give the project a name.")
|
|
427
|
+
# Checked here rather than left to the filesystem, whose answer depends on the Python:
|
|
428
|
+
# 3.12's `is_dir` raises "File name too long" and 3.14's returns False and carries on.
|
|
429
|
+
# 255 is the limit on ext4, APFS and NTFS alike; the name is not repeated, so the reason
|
|
430
|
+
# stays on screen in a prompt's one-line error.
|
|
431
|
+
if len(name.encode("utf-8")) > MAX_FOLDER_NAME_BYTES:
|
|
432
|
+
raise GeneratorError(
|
|
433
|
+
f"That folder name is {len(name.encode('utf-8'))} bytes; a folder name may be at most "
|
|
434
|
+
f"{MAX_FOLDER_NAME_BYTES}."
|
|
435
|
+
)
|
|
436
|
+
bad = sorted({character for character in name if character in RESERVED_CHARACTERS})
|
|
437
|
+
if bad:
|
|
438
|
+
shown = " ".join(repr(character) for character in bad)
|
|
439
|
+
raise GeneratorError(f"{name!r} contains {shown}, which Windows does not allow in a folder name.")
|
|
440
|
+
if name[-1] in ". ":
|
|
441
|
+
raise GeneratorError(f"{name!r} ends with a {'dot' if name[-1] == '.' else 'space'}, which Windows removes.")
|
|
442
|
+
if name.split(".")[0].rstrip(" ").upper() in RESERVED_DEVICE_NAMES:
|
|
443
|
+
raise GeneratorError(f"{name!r} is a device name Windows reserves. Choose another name.")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def check_new_path(given: Path) -> None:
|
|
447
|
+
"""Check every folder name in the path as typed, except folders that already exist.
|
|
448
|
+
|
|
449
|
+
An existing folder has already proved the filesystem accepts it. Anything else is checked
|
|
450
|
+
by name, including a device name that Windows reports as existing although it is no
|
|
451
|
+
folder at all.
|
|
452
|
+
"""
|
|
453
|
+
parts = Path(given).parts
|
|
454
|
+
for index, name in enumerate(parts):
|
|
455
|
+
if name in (".", "..") or name == Path(given).anchor:
|
|
456
|
+
continue
|
|
457
|
+
# Before the lookup, which raises on such a name on some Pythons and not on others.
|
|
458
|
+
if len(name.encode("utf-8")) > MAX_FOLDER_NAME_BYTES:
|
|
459
|
+
check_folder_name(name)
|
|
460
|
+
if Path(*parts[: index + 1]).is_dir():
|
|
461
|
+
continue
|
|
462
|
+
check_folder_name(name)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def distribution_name(folder: str) -> str:
|
|
466
|
+
"""The package name a folder name becomes, so an ordinary folder name is never refused.
|
|
467
|
+
|
|
468
|
+
`Gate Study (v2)` is a perfectly good folder and not a valid distribution name. Accents
|
|
469
|
+
are folded first, so `Étude` becomes `etude` rather than losing its first letter; then
|
|
470
|
+
runs of anything that is not a letter or digit become one hyphen, lowercased, which is
|
|
471
|
+
the spelling PyPA normalises every name to anyway.
|
|
472
|
+
"""
|
|
473
|
+
ascii_only = unicodedata.normalize("NFKD", folder).encode("ascii", "ignore").decode("ascii")
|
|
474
|
+
derived = re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")
|
|
475
|
+
if not derived:
|
|
476
|
+
raise GeneratorError(
|
|
477
|
+
f"{folder!r} holds no ASCII letters or digits, so no project name can be made from it. Rename the folder."
|
|
478
|
+
)
|
|
479
|
+
return derived
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
#: PEP 508 names the grammar a distribution name must match. Applied to a name passed
|
|
483
|
+
#: explicitly; a name taken from the folder is derived into this shape first.
|
|
484
|
+
PROJECT_NAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class GeneratorError(Exception):
|
|
488
|
+
"""A refusal the caller can act on: an unusable name, a clash, a symlink in a template.
|
|
489
|
+
|
|
490
|
+
Separate from `ValueError`, which the generator still raises for a defect in this
|
|
491
|
+
repository — a shared instruction base with no trailing newline, say. Those should reach
|
|
492
|
+
a maintainer as a traceback rather than being printed to a customer as if they had done
|
|
493
|
+
something wrong.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class ConflictError(GeneratorError):
|
|
498
|
+
"""Files the template would write already exist with different content."""
|
|
499
|
+
|
|
500
|
+
def __init__(self, destination: Path, clashes: list[str], on_disk: dict[str, str] | None = None) -> None:
|
|
501
|
+
"""Name every clash, and say so plainly when the folder is already a project."""
|
|
502
|
+
self.destination = destination
|
|
503
|
+
self.clashes = clashes
|
|
504
|
+
on_disk = on_disk or {}
|
|
505
|
+
named = [f"{on_disk[c]} (would become {c})" if on_disk.get(c, c) != c else c for c in clashes]
|
|
506
|
+
if STAMP in clashes:
|
|
507
|
+
previous = _previous_template(Path(destination) / STAMP)
|
|
508
|
+
what = f"a project generated from {previous!r}" if previous else "a generated project"
|
|
509
|
+
opening = f"{destination} already holds {what}."
|
|
510
|
+
advice = "Generate into a new folder beside it, or pass --force to overwrite"
|
|
511
|
+
else:
|
|
512
|
+
opening = f"{destination} already has {len(clashes)} file(s) this template would replace."
|
|
513
|
+
advice = "Pass --force to overwrite"
|
|
514
|
+
super().__init__(
|
|
515
|
+
f"{opening} Conflicting: {format_listing(named)}. {advice} those files; any changes you made "
|
|
516
|
+
"to them are lost, and nothing else in the folder is touched."
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class WriteFailed(GeneratorError):
|
|
521
|
+
"""Writing stopped part-way into a folder that was already there, so it was left as is."""
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _previous_template(stamp_file: Path) -> str | None:
|
|
525
|
+
try:
|
|
526
|
+
record = json.loads(stamp_file.read_text(encoding="utf-8"))
|
|
527
|
+
except (OSError, ValueError):
|
|
528
|
+
return None
|
|
529
|
+
template = record.get("template") if isinstance(record, dict) else None
|
|
530
|
+
return template if isinstance(template, str) else None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def check_project_name(name: str) -> None:
|
|
534
|
+
"""Fail on a name the generated manifest could not carry.
|
|
535
|
+
|
|
536
|
+
Interpolating an unchecked name into TOML is the sharper half: a name containing a
|
|
537
|
+
double quote produces a `pyproject.toml` that does not parse at all.
|
|
538
|
+
"""
|
|
539
|
+
if not PROJECT_NAME.fullmatch(name):
|
|
540
|
+
raise GeneratorError(
|
|
541
|
+
f"{name!r} is not a usable project name. It must start and end with a letter or "
|
|
542
|
+
"digit and hold only letters, digits, '.', '-' and '_'."
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _copy_file(source: Path, destination: Path) -> None:
|
|
547
|
+
"""Copy one file, normalising text to LF.
|
|
548
|
+
|
|
549
|
+
`.gitattributes` checks the template trees out as LF everywhere, so this is belt and
|
|
550
|
+
braces there — but it still does real work for anything copied from outside them, where
|
|
551
|
+
the repository-wide `text=auto` checks out **native** and a byte copy ships CRLF while
|
|
552
|
+
the files written here are LF. A generated project would then depend on the machine it was generated on: the same
|
|
553
|
+
template produces different bytes on Windows and Linux, and two customers could not be
|
|
554
|
+
compared. The repository already declares LF as its line ending, so this carries that
|
|
555
|
+
through to what a customer receives rather than inventing a rule.
|
|
556
|
+
|
|
557
|
+
Binary is detected by a NUL byte and copied untouched — normalising an `.stl` would
|
|
558
|
+
corrupt the geometry.
|
|
559
|
+
"""
|
|
560
|
+
data = source.read_bytes()
|
|
561
|
+
if b"\x00" in data:
|
|
562
|
+
shutil.copy2(source, destination)
|
|
563
|
+
return
|
|
564
|
+
destination.write_bytes(data.replace(b"\r\n", b"\n"))
|
|
565
|
+
shutil.copystat(source, destination)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _copy_tree(source: Path, destination: Path) -> None:
|
|
569
|
+
"""Copy a template or package, leaving a developer's working tree behind.
|
|
570
|
+
|
|
571
|
+
Hand-walked rather than `shutil.copytree`, for two reasons its options cannot cover.
|
|
572
|
+
A symlink is **refused**: `copytree` defaults to following one, so a link inside a
|
|
573
|
+
template would copy the content of its target from anywhere on the developer's disk,
|
|
574
|
+
and a symlink in a customer's project is separately unwanted — on Windows it needs
|
|
575
|
+
Administrator, and git without symlink support writes a plain file holding the target's
|
|
576
|
+
name, which loads successfully and conveys nothing.
|
|
577
|
+
"""
|
|
578
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
579
|
+
for entry in sorted(source.iterdir()):
|
|
580
|
+
if is_excluded(entry):
|
|
581
|
+
continue
|
|
582
|
+
if entry.is_symlink():
|
|
583
|
+
raise GeneratorError(
|
|
584
|
+
f"{entry} is a symlink. Templates ship real files: a link would be copied as its "
|
|
585
|
+
"target's contents, which may sit anywhere on this machine."
|
|
586
|
+
)
|
|
587
|
+
if entry.is_dir():
|
|
588
|
+
_copy_tree(entry, destination / entry.name)
|
|
589
|
+
else:
|
|
590
|
+
_copy_file(entry, destination / entry.name)
|