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.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,152 @@
1
+ """Assemble the instruction file a generated project receives, and the three files written from it.
2
+
3
+ A template's `AGENTS.md` is half a file. The generic Cadmould rules are authored once in
4
+ `templates/_shared/AGENTS.base.md`; the template's own half describes only its workflow.
5
+ The file a customer's assistant actually reads therefore exists nowhere on disk in this
6
+ repository — it is produced here, and this module is the only place that join happens.
7
+
8
+ Two of the three derived files are one-line imports and the third is a byte copy, and
9
+ that asymmetry is vendor behaviour rather than taste:
10
+
11
+ * `.github/copilot-instructions.md` is the only instruction file read on every Copilot
12
+ surface, and Visual Studio chat, JetBrains chat, Xcode and Eclipse do not read
13
+ `AGENTS.md` at all — so a pointer there would be read by nothing. It carries the same
14
+ content rather than a reference, and its relative links are re-rooted as it is written:
15
+ it sits one directory deeper, so a bare `METHOD.md` correct at the project root would
16
+ resolve to `.github/METHOD.md` and find nothing. Same content, one adjustment, made in
17
+ the one place the copy happens — so the two still cannot drift apart.
18
+ * `CLAUDE.md` and `GEMINI.md` stay one-line imports. Claude Code reads `AGENTS.md`
19
+ directly in recent versions but not in Bedrock or telemetry-disabled sessions, where
20
+ the import is the documented fallback. It never causes a double read.
21
+
22
+ An `@path` import does not reduce context — an imported file loads exactly like inlined
23
+ text — so indirection is an organisation tool here, never a way to buy length.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ from simcon_toolkit.manifest import TEMPLATES_ROOT
32
+ from simcon_toolkit.project import RUN_RULES
33
+
34
+ SHARED_BASE = TEMPLATES_ROOT / "_shared" / "AGENTS.base.md"
35
+
36
+ #: What the assembled file must stay under. Vendor guidance, not a house style.
37
+ MAX_ASSEMBLED_LINES = 200
38
+
39
+ #: The pointer the two import-capable tools receive. Bare on its own line: an `@` import
40
+ #: is silently skipped when the path is followed by a colon or a comma.
41
+ POINTER = "@AGENTS.md\n"
42
+
43
+ #: Written from the assembled file rather than authored, so there is one source.
44
+ POINTER_FILES = ("CLAUDE.md", "GEMINI.md")
45
+ COPILOT_FILE = Path(".github") / "copilot-instructions.md"
46
+
47
+ #: A markdown inline link or image, split so the target can be rewritten in place.
48
+ MARKDOWN_LINK = re.compile(r"(!?\[[^\]]*\]\()([^)]+)(\))")
49
+
50
+ #: A reference-style definition — `[guide]: ./GUIDE.md "Title"` — on its own line. A second
51
+ #: shape rather than a wider pattern, because this one carries no parentheses to anchor on.
52
+ #: Grouped the same way as the inline pattern, prefix / target / rest, so one rewrite and one
53
+ #: extraction serve both. Four spaces of indent is a code block, not a definition.
54
+ MARKDOWN_LINK_DEFINITION = re.compile(r"(^[ ]{0,3}\[[^\]]+\]:[ \t]*)(\S+)(.*)$", re.MULTILINE)
55
+
56
+ #: Both shapes a relative target can take.
57
+ LINK_PATTERNS = (MARKDOWN_LINK, MARKDOWN_LINK_DEFINITION)
58
+
59
+ #: Targets that are already absolute, or are not paths at all.
60
+ _NOT_A_RELATIVE_PATH = ("http://", "https://", "//", "#", "mailto:", "/")
61
+
62
+
63
+ def reroot_relative_links(text: str, prefix: str) -> str:
64
+ """Prefix every relative link target, leaving absolute ones and anchors alone.
65
+
66
+ Written for one job: the Copilot copy sits one directory below the file it copies, so
67
+ every relative target in it is wrong by exactly that one level.
68
+
69
+ Both link shapes are rewritten. Handling only the inline one would leave the rewriter
70
+ and the check that guards it disagreeing about what a link is — and the half that ships
71
+ to the customer would be the one that is wrong.
72
+ """
73
+
74
+ def rewrite(match: re.Match[str]) -> str:
75
+ opening, target, closing = match.groups()
76
+ # A title after the path — `[x](path "Title")` — must not be prefixed with it.
77
+ path, separator, title = target.partition(" ")
78
+ bare = path.strip("<>")
79
+ if not bare or bare.startswith(_NOT_A_RELATIVE_PATH):
80
+ return match.group(0)
81
+ rerooted = path.replace(bare, prefix + bare, 1)
82
+ return f"{opening}{rerooted}{separator}{title}{closing}"
83
+
84
+ for pattern in LINK_PATTERNS:
85
+ text = pattern.sub(rewrite, text)
86
+ return text
87
+
88
+
89
+ def assembled(template: Path, shared_base: Path = SHARED_BASE, package_manager: str | None = None) -> str:
90
+ """The `AGENTS.md` a generated project receives: the shared base, then this template's half.
91
+
92
+ With a package manager, the rule for running a command sits between the two. Without one
93
+ the result is the plain join, which is what the repository's own checks measure.
94
+
95
+ The blank line between the parts comes from each part's own trailing newline plus the
96
+ separator added here. `check_shared_base` is what keeps that true for the base.
97
+ """
98
+ parts = [shared_base.read_text(encoding="utf-8")]
99
+ if package_manager is not None:
100
+ parts.append(RUN_RULES[package_manager])
101
+ parts.append((template / "AGENTS.md").read_text(encoding="utf-8"))
102
+ return "\n".join(parts)
103
+
104
+
105
+ def check_shared_base(shared_base: Path = SHARED_BASE) -> None:
106
+ """Fail when the base cannot produce a blank line between the two halves.
107
+
108
+ The separator is one newline, so the base must already end with one. Without that the
109
+ last line of the shared rules and the first line of the template's half run together
110
+ into a single line, which no line-count or link check would notice.
111
+ """
112
+ text = shared_base.read_text(encoding="utf-8")
113
+ if not text.endswith("\n"):
114
+ raise ValueError(
115
+ f"{shared_base} does not end with a newline, so the assembled AGENTS.md would run "
116
+ "the shared half straight into the template's half on one line."
117
+ )
118
+
119
+
120
+ def write_instruction_files(
121
+ destination: Path, template: Path, shared_base: Path = SHARED_BASE, package_manager: str | None = None
122
+ ) -> list[Path]:
123
+ """Write the assembled `AGENTS.md` and the three files derived from it.
124
+
125
+ Returns the paths written, so a caller can report them without re-deriving the list.
126
+ """
127
+ check_shared_base(shared_base)
128
+ text = assembled(template, shared_base, package_manager)
129
+
130
+ written = []
131
+ agents = destination / "AGENTS.md"
132
+ # newline="\n" on every write a customer receives: Python otherwise translates to the
133
+ # platform separator, so a project generated on Windows would carry CRLF while the same
134
+ # project generated on Linux carries LF, and the two would not be byte-comparable.
135
+ agents.write_text(text, encoding="utf-8", newline="\n")
136
+ written.append(agents)
137
+
138
+ for name in POINTER_FILES:
139
+ pointer = destination / name
140
+ pointer.write_text(POINTER, encoding="utf-8", newline="\n")
141
+ written.append(pointer)
142
+
143
+ # The same text, with relative link targets moved up one level for the directory it
144
+ # sits in. Written from the same string rather than re-derived, so the two cannot
145
+ # drift in content; only the paths differ, and only because they have to.
146
+ copilot = destination / COPILOT_FILE
147
+ copilot.parent.mkdir(parents=True, exist_ok=True)
148
+ depth = len(COPILOT_FILE.parent.parts)
149
+ copilot.write_text(reroot_relative_links(text, "../" * depth), encoding="utf-8", newline="\n")
150
+ written.append(copilot)
151
+
152
+ return written
@@ -0,0 +1,86 @@
1
+ """Read `templates.toml`, the one description of what each template is.
2
+
3
+ This lives in the installer rather than beside the generator because the installer is the
4
+ only copy that ships. Resolving against `KIT_ROOT` is what makes one reader serve both: in
5
+ a published wheel it points inside the package, and in a source checkout it is the
6
+ repository root, so the repository's own tooling reads exactly what a customer's copy does.
7
+
8
+ The manifest's parity test already fails when a field drifts from the filesystem, so
9
+ nothing here revalidates what that test guarantees — this module only reads.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tomllib
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from simcon_toolkit import KIT_ROOT
19
+
20
+ ROOT = KIT_ROOT
21
+ MANIFEST = ROOT / "templates.toml"
22
+ TEMPLATES_ROOT = ROOT / "templates"
23
+ PACKAGES_ROOT = ROOT / "packages"
24
+ ASSETS_ROOT = ROOT / "assets"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TemplateSpec:
29
+ """One `[templates.<name>]` table, with the paths it names already resolved."""
30
+
31
+ name: str
32
+ title: str
33
+ description: str
34
+ category: str
35
+ client: str
36
+ needs: tuple[str, ...]
37
+ heavy: bool
38
+ modules: tuple[str, ...]
39
+ sample: str
40
+ package: str | None
41
+
42
+ @property
43
+ def directory(self) -> Path:
44
+ """Where this template's files live."""
45
+ return TEMPLATES_ROOT / self.name
46
+
47
+ @property
48
+ def sample_source(self) -> Path:
49
+ """The geometry a generated project receives, so it runs the moment it exists."""
50
+ return ASSETS_ROOT / "parts" / self.sample
51
+
52
+ def module_sources(self) -> list[Path]:
53
+ """The shared packages copied in beside the workflow, each under its own name."""
54
+ return [PACKAGES_ROOT / module for module in self.modules]
55
+
56
+
57
+ def load_manifest(manifest: Path = MANIFEST) -> dict[str, TemplateSpec]:
58
+ """Every template the manifest describes, keyed by name."""
59
+ tables = tomllib.loads(manifest.read_text(encoding="utf-8"))["templates"]
60
+ return {
61
+ name: TemplateSpec(
62
+ name=name,
63
+ title=table["title"],
64
+ description=table["description"],
65
+ category=table["category"],
66
+ client=table["client"],
67
+ needs=tuple(table["needs"]),
68
+ heavy=table["heavy"],
69
+ modules=tuple(table["modules"]),
70
+ sample=table["sample"],
71
+ # Absent for a template that is a single script at its root, so a reader
72
+ # must tolerate None rather than assume every template has an inner folder.
73
+ package=table.get("package"),
74
+ )
75
+ for name, table in tables.items()
76
+ }
77
+
78
+
79
+ def load_template(name: str, manifest: Path = MANIFEST) -> TemplateSpec:
80
+ """One template by name, with the available names listed when it is unknown."""
81
+ templates = load_manifest(manifest)
82
+ try:
83
+ return templates[name]
84
+ except KeyError:
85
+ known = ", ".join(sorted(templates))
86
+ raise KeyError(f"no template named {name!r}. Available: {known}") from None
@@ -0,0 +1,356 @@
1
+ """The files a generated project gets that exist in no template: its manifest, licence and stamp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import tomllib
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from simcon_toolkit.manifest import ROOT, TemplateSpec
12
+
13
+ KIT_PYPROJECT = ROOT / "pyproject.toml"
14
+ LICENSE_SOURCE = ROOT / "LICENSE"
15
+ NOTICE_SOURCE = ROOT / "NOTICE"
16
+
17
+ #: A floor and no ceiling. The floor turns a confusing failure deep inside a workflow into
18
+ #: "you need Python 3.12 or newer"; a ceiling would only refuse people, and a virtual
19
+ #: environment cannot work around one because it isolates packages, not the interpreter.
20
+ GENERATED_REQUIRES_PYTHON = ">=3.12"
21
+
22
+ #: The interpreter uv selects for the project, and downloads if the machine lacks it. The
23
+ #: floor above only refuses; this is what actually steers, because uv follows it silently.
24
+ #: The minor rather than a patch release, which would be a maintenance chore with no benefit.
25
+ PYTHON_VERSION_PIN = "3.12"
26
+
27
+ #: Git for Windows installs with `core.autocrlf=true`, which checks every text file out as
28
+ #: CRLF. Everything written here is LF, and an assistant writing LF into a CRLF working copy
29
+ #: turns a one-line edit into a whole-file diff. An attribute is the one setting git lets a
30
+ #: repository impose over each person's own configuration. Binary STL needs no rule: its
31
+ #: triangle count always carries a zero byte, which is how git recognises binary.
32
+ GITATTRIBUTES = "* text=auto eol=lf\n"
33
+
34
+ #: Addresses a customer can actually open. The repository URL is deliberately absent: this
35
+ #: repository is internal, so a link to it answers 404 for every customer who follows it.
36
+ PROJECT_URLS = {
37
+ # simcon.com permanently redirects here; pointing at the destination spares a customer
38
+ # a hop and keeps the link honest if the old domain is ever retired.
39
+ "Homepage": "https://www.simcon.ai",
40
+ "Documentation": "https://api.simcon.ai/scalar",
41
+ "Instructions for AI assistants": "https://agents.simcon.ai/start.md",
42
+ }
43
+
44
+ #: SIMCON's private package index, where the licensed `cadmould` wheel lives: the customer
45
+ #: channel, and the address `cadmould-sdk-auth` itself publishes on PyPI. Not a secret — AWS
46
+ #: states an account id is not one — and it carries no token, which uv keeps in its own
47
+ #: credential store.
48
+ CADMOULD_INDEX = "https://cadmould-620794556704.d.codeartifact.eu-central-1.amazonaws.com/pypi/cadmould-python/simple/"
49
+
50
+ #: Written into every project, whichever installer was chosen: pip ignores `[tool.uv]`, so a
51
+ #: project a colleague opens with the other installer still installs. `explicit` plus the
52
+ #: source pin is the part that matters. Without it uv checks public PyPI first, finds our
53
+ #: defensive `cadmould 0.0.1` there, and stops.
54
+ UV_INDEX_BLOCK = f"""\
55
+ # uv reads these two tables and pip ignores them. They send `cadmould`, and nothing else,
56
+ # to SIMCON's private index; everything else still comes from PyPI.
57
+ [[tool.uv.index]]
58
+ name = "cadmould"
59
+ url = "{CADMOULD_INDEX}"
60
+ explicit = true
61
+
62
+ [tool.uv.sources]
63
+ cadmould = {{ index = "cadmould" }}
64
+ """
65
+
66
+ #: The README's setup block for a uv project, replacing the pip route the template carries.
67
+ #: One spelling for bash and PowerShell on purpose: a pipe into `--password -` works in both,
68
+ #: uv strips the CRLF Windows PowerShell appends, and it avoids `| iex`, the shape endpoint
69
+ #: security flags.
70
+ UV_SETUP = f"""\
71
+ **uv installs everything, Python 3.12 included.** Install uv once: on Windows
72
+ `winget install --id=astral-sh.uv -e`, then open a new terminal so it is on the path;
73
+ otherwise see <https://docs.astral.sh/uv/getting-started/installation/>.
74
+
75
+ **Three sign-ins, each in your browser, and each does a different job.**
76
+
77
+ 1. **The package index**, the first line below: it hands uv a token for SIMCON's private index,
78
+ where the licensed `cadmould` wheel lives. Your company's email domain has to be enabled for
79
+ it: if you can sign in but the browser tab then says `Access denied: your email domain is not
80
+ authorized for this API`, ask SIMCON support.
81
+ 2. **Your licence**, the first time the code opens a licence session. It first checks the
82
+ Thales Sentinel run-time on your machine, and prints `License init failed: HASP driver
83
+ version too old` or `... runtime version too old` when that needs installing or updating.
84
+ 3. **The cloud**, the first time the code calls it.
85
+
86
+ ⚠️ A failed licence sign-in does not stop the script. It prints `License init failed: ...` or
87
+ `License acquisition failed.` to the terminal and carries on, so the error you meet later is `Cadmould API licence (LICENCE_API)
88
+ required`. When you see that one, scroll up to the first.
89
+
90
+ From this folder, the same two lines in bash and in PowerShell:
91
+
92
+ ```
93
+ uvx cadmould-sdk-auth --print-token | uv auth login {CADMOULD_INDEX} --username aws --password -
94
+ uv sync
95
+ ```
96
+
97
+ The token lasts about an hour, and uv needs it only while it installs something, so
98
+ `uv run` keeps working after it expires. If a later `uv sync` or `uv add` fails with
99
+ `401 Unauthorized`, run the first line again.
100
+
101
+ **Run every command in this README through uv.** Where it says `python main.py ...`, run
102
+ `uv run main.py ...`; where it says `python -m ...`, run `uv run python -m ...`. uv uses this
103
+ project's own environment, so there is nothing to activate on any operating system.
104
+ """
105
+
106
+ #: What the instruction file says about running a command, per installer. Every playbook and
107
+ #: every hint the code prints says `python main.py`, which in a uv project runs with no
108
+ #: environment active and fails on an import the assistant then misdiagnoses.
109
+ RUN_RULES = {
110
+ "uv": """\
111
+ ## Running a command in this project
112
+
113
+ This project installs with uv. Run every `python ...` command the playbooks and the code's
114
+ own hints show through uv: `uv run main.py ...` for `python main.py ...`, and
115
+ `uv run python -m ...` for `python -m ...`. Never `pip install` here, and pass uv no
116
+ `--index-url` or `--extra-index-url`: `cadmould` is already pinned to SIMCON's private index
117
+ in `pyproject.toml`, so no index flag is ever needed. A `401 Unauthorized` from uv means the
118
+ index token expired; the README's first setup line renews it.
119
+ """,
120
+ "pip": """\
121
+ ## Running a command in this project
122
+
123
+ This project installs with pip into `.venv`. Run every `python ...` command with that
124
+ environment's interpreter: `.venv/bin/python` on macOS and Linux, `.venv\\Scripts\\python.exe`
125
+ on Windows. The system `python` does not have `cadmould` installed.
126
+ """,
127
+ }
128
+
129
+ SAMPLE_README = """\
130
+ # Sample geometry
131
+
132
+ `{sample}` is sample geometry shipped so this project runs the moment you generate it.
133
+
134
+ Replace the file, or pass your own with `--part path/to/your.stl`. Nothing here is
135
+ special: once you are running your own parts you can delete this folder.
136
+ """
137
+
138
+
139
+ #: Never copied into a customer's project. Compiled output was the first prototype's
140
+ #: mistake; the rest is a developer's working tree, which a template directory accumulates
141
+ #: and which `shutil.copytree` would otherwise ship verbatim. `.env` is the one that matters:
142
+ #: every template's own .gitignore says "never commit credentials", and shipping a
143
+ #: developer's licence identity to a customer would make the generator the leak.
144
+ EXCLUDED_NAMES = frozenset(
145
+ {
146
+ "__pycache__",
147
+ ".pytest_cache",
148
+ ".ruff_cache",
149
+ ".venv",
150
+ "venv",
151
+ ".DS_Store",
152
+ "Thumbs.db",
153
+ # Written by a template when it is run in place; ours to regenerate, never to ship.
154
+ # `build` is one of these, not a packaging directory: run-one-simulation writes its
155
+ # meshed part and downloaded result there.
156
+ "build",
157
+ "cache",
158
+ "output",
159
+ # The generator writes sample/ itself, from the one copy under assets/parts/.
160
+ "sample",
161
+ }
162
+ )
163
+
164
+ #: Suffix and prefix rules for the same job. `.env`, `.env.local`, `token.json` and a
165
+ #: compiled module are all things a working tree grows and a customer must never receive.
166
+ EXCLUDED_SUFFIXES = (".pyc", ".pyo", ".egg-info")
167
+ EXCLUDED_PREFIXES = (".env",)
168
+
169
+ #: Read back in after the prefix rule above, because it names the variables a customer has
170
+ #: to set and carries no value. Excluding it would delete the one file that says what a
171
+ #: real `.env` should contain.
172
+ #:
173
+ #: Matched by NAME ONLY, and it has to stay that way: `content_digest` calls `is_excluded`
174
+ #: on bare relative path components, where any filesystem question would be asked of the
175
+ #: process working directory rather than of the template. A predicate that touched the disk
176
+ #: would answer differently for the copy and for the digest, which is the one thing this
177
+ #: module exists to prevent. That a directory of this name is therefore copied too is
178
+ #: refused in the repository instead, where the check is deterministic.
179
+ PREFIX_EXCEPTIONS = frozenset({".env.example"})
180
+
181
+
182
+ def is_excluded(path: Path) -> bool:
183
+ """Whether a path is a developer's working tree rather than part of the template.
184
+
185
+ Used by BOTH the copy and the digest, which is the point of it living here. If the two
186
+ ever disagree, an untracked file in a template directory changes the digest without
187
+ changing the project — so two clean checkouts would report different digests for
188
+ identical content and the stamp would stop meaning anything.
189
+ """
190
+ name = path.name
191
+ if name in PREFIX_EXCEPTIONS:
192
+ return False
193
+ return name in EXCLUDED_NAMES or name.endswith(EXCLUDED_SUFFIXES) or name.startswith(EXCLUDED_PREFIXES)
194
+
195
+
196
+ def _file_digest(path: Path) -> str:
197
+ """Hash one file, with text normalised to LF first.
198
+
199
+ `.gitattributes` pins the template trees to LF everywhere, but the repository-wide rule
200
+ is `text=auto`, which checks out **native** — so a file from outside those trees is CRLF
201
+ in a Windows checkout and LF in a Linux one. Hashing raw bytes
202
+ would therefore give two digests for identical content, and the stamp would identify the
203
+ operating system rather than the template. Binary is detected by a NUL byte and hashed
204
+ untouched, because normalising a `.stl` would corrupt the very thing being identified.
205
+ """
206
+ data = path.read_bytes()
207
+ if b"\x00" not in data:
208
+ data = data.replace(b"\r\n", b"\n")
209
+ return hashlib.sha256(data).hexdigest()
210
+
211
+
212
+ def content_digest(sources: list[Path]) -> str:
213
+ """A digest of the template content a project was generated from.
214
+
215
+ Keyed on bytes rather than on a version number, because a template can change
216
+ substantially while every version string in the repository stays still. It is what
217
+ lets a support engineer tell two projects apart when nobody bumped anything.
218
+ """
219
+ digest = hashlib.sha256()
220
+ for source in sorted(sources):
221
+ # A source may be a single file — the shared instruction base is one — and rglob on
222
+ # a file yields nothing, so it would contribute silently nothing to the digest.
223
+ candidates = [source] if source.is_file() else sorted(p for p in source.rglob("*") if p.is_file())
224
+ for path in candidates:
225
+ # The same rule the copy uses, so an untracked file cannot move the digest
226
+ # without moving the project.
227
+ if any(is_excluded(Path(part)) for part in path.relative_to(source).parts) or is_excluded(path):
228
+ continue
229
+ # as_posix(), never str(): `str()` on a Windows path yields backslashes, so the
230
+ # same content would digest differently on the two runners CI uses and the field
231
+ # would stop identifying anything.
232
+ digest.update(path.relative_to(source.parent).as_posix().encode("utf-8"))
233
+ digest.update(_file_digest(path).encode("ascii"))
234
+ return digest.hexdigest()
235
+
236
+
237
+ def kit_version(pyproject: Path = KIT_PYPROJECT) -> str:
238
+ """The kit's own version, parsed rather than matched.
239
+
240
+ A regex for `^version = "..."` is correct only while `[project]` is the first table
241
+ carrying that key, which nothing enforces.
242
+ """
243
+ return str(tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"])
244
+
245
+
246
+ def _kit_dependencies(pyproject: Path = KIT_PYPROJECT) -> tuple[list[str], dict[str, list[str]]]:
247
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]
248
+ return data["dependencies"], data.get("optional-dependencies", {})
249
+
250
+
251
+ def dependencies_for(spec: TemplateSpec, pyproject: Path = KIT_PYPROJECT) -> list[str]:
252
+ """Everything the generated project needs, in one list.
253
+
254
+ A single-template project has no use for extras — its one template's dependencies are
255
+ simply its dependencies — so the kit's shared core and the template's own extra are
256
+ merged here and `pip install .` is the whole story.
257
+ """
258
+ core, extras = _kit_dependencies(pyproject)
259
+ merged = list(core)
260
+ for requirement in extras.get(spec.name, []):
261
+ if requirement not in merged:
262
+ merged.append(requirement)
263
+ return merged
264
+
265
+
266
+ def _toml_string(value: str) -> str:
267
+ """One TOML basic string, fully escaped.
268
+
269
+ `json.dumps` rather than a hand-rolled quote-and-backslash swap: TOML basic strings and
270
+ JSON strings share an escape syntax, and JSON covers what the swap missed — a newline, a
271
+ tab, a control character. Verified by round-tripping each of those through `tomllib`.
272
+ `ensure_ascii=False` keeps non-ASCII readable, which TOML allows.
273
+
274
+ A value that closes the string early produces a manifest that does not parse, and that
275
+ failure reaches the customer rather than us.
276
+ """
277
+ return json.dumps(value, ensure_ascii=False)
278
+
279
+
280
+ def render_pyproject(spec: TemplateSpec, project_name: str, pyproject: Path = KIT_PYPROJECT) -> str:
281
+ """The generated project's `pyproject.toml`, declaring what its one template needs."""
282
+ # Encoded like every other string: a PEP 508 environment marker legitimately contains
283
+ # quotes -- `httpx>=0.27; python_version >= "3.12"` -- so an unescaped requirement is one
284
+ # marker away from a manifest that does not parse.
285
+ deps = "\n".join(f" {_toml_string(requirement)}," for requirement in dependencies_for(spec, pyproject))
286
+ urls = "\n".join(f"{_toml_string(label)} = {_toml_string(url)}" for label, url in PROJECT_URLS.items())
287
+ return f"""\
288
+ [build-system]
289
+ requires = ["setuptools>=77"]
290
+ build-backend = "setuptools.build_meta"
291
+
292
+ [project]
293
+ name = {_toml_string(project_name)}
294
+ version = "0.1.0"
295
+ description = {_toml_string(spec.description)}
296
+ readme = "README.md"
297
+ # A floor and no ceiling. A virtual environment isolates packages, not the interpreter, so
298
+ # a ceiling refuses people who cannot work around it; the floor turns a confusing error
299
+ # deep inside a workflow into a clear one at install time.
300
+ requires-python = "{GENERATED_REQUIRES_PYTHON}"
301
+ dependencies = [
302
+ {deps}
303
+ ]
304
+
305
+ [project.urls]
306
+ {urls}
307
+
308
+ # The workflow is scripts you run and edit, not a library to import, so the distribution
309
+ # ships no packages. This file is here for the dependency list.
310
+ [tool.setuptools]
311
+ packages = []
312
+
313
+ {UV_INDEX_BLOCK}"""
314
+
315
+
316
+ def stamp(
317
+ spec: TemplateSpec,
318
+ digest: str,
319
+ *,
320
+ generated_at: str | None = None,
321
+ cli_version: str | None = None,
322
+ package_manager: str,
323
+ ) -> dict[str, object]:
324
+ """What `.simcon-toolkit.json` records, so support can tell which template this is.
325
+
326
+ `template_digest` is the load-bearing field: version numbers move only when somebody
327
+ remembers, and the kit has sat at one version through every template change so far.
328
+ """
329
+ return {
330
+ "template": spec.name,
331
+ "template_digest": digest,
332
+ "starter_kit_version": kit_version(),
333
+ # Absent until the CLI exists and fills it in; recorded as null rather than omitted
334
+ # so a reader can tell "no CLI" from "an older stamp that had no such field".
335
+ "cli_version": cli_version,
336
+ # Which instructions the project was given, so support reads the right route.
337
+ "package_manager": package_manager,
338
+ "generated_at": generated_at or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
339
+ "needs": list(spec.needs),
340
+ "client": spec.client,
341
+ }
342
+
343
+
344
+ def write_sample(destination: Path, spec: TemplateSpec) -> list[Path]:
345
+ """Copy the one shipped part into the project, and say it is disposable.
346
+
347
+ A binary `.stl` cannot carry a header comment the way an example env file can, so the
348
+ folder has to say it in a sibling file.
349
+ """
350
+ sample_dir = destination / "sample"
351
+ sample_dir.mkdir(parents=True, exist_ok=True)
352
+ part = sample_dir / spec.sample
353
+ part.write_bytes(spec.sample_source.read_bytes())
354
+ readme = sample_dir / "README.md"
355
+ readme.write_text(SAMPLE_README.format(sample=spec.sample), encoding="utf-8", newline="\n")
356
+ return [part, readme]