xtr-cli 4.0.2__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 (73) hide show
  1. xtr_cli/__init__.py +79 -0
  2. xtr_cli/__main__.py +28 -0
  3. xtr_cli/add_on.py +52 -0
  4. xtr_cli/add_on_catalog.py +167 -0
  5. xtr_cli/add_on_choice.py +40 -0
  6. xtr_cli/command/__init__.py +14 -0
  7. xtr_cli/command/init_command.py +69 -0
  8. xtr_cli/command/new_command.py +72 -0
  9. xtr_cli/command/serve_command.py +114 -0
  10. xtr_cli/command_support.py +189 -0
  11. xtr_cli/exception/__init__.py +34 -0
  12. xtr_cli/exception/ambiguous_web_entry_point_error.py +27 -0
  13. xtr_cli/exception/cli_error.py +14 -0
  14. xtr_cli/exception/directory_not_empty_error.py +35 -0
  15. xtr_cli/exception/invalid_project_name_error.py +33 -0
  16. xtr_cli/exception/invalid_template_error.py +28 -0
  17. xtr_cli/exception/project_not_found_error.py +28 -0
  18. xtr_cli/exception/step_failed_error.py +38 -0
  19. xtr_cli/exception/unknown_add_on_error.py +28 -0
  20. xtr_cli/exception/unknown_template_error.py +29 -0
  21. xtr_cli/exception/unsupported_python_error.py +28 -0
  22. xtr_cli/exception/web_entry_point_not_found_error.py +31 -0
  23. xtr_cli/process_runner_interface.py +29 -0
  24. xtr_cli/project_name.py +55 -0
  25. xtr_cli/project_questions.py +108 -0
  26. xtr_cli/project_settings.py +89 -0
  27. xtr_cli/py.typed +0 -0
  28. xtr_cli/scaffold_plan.py +41 -0
  29. xtr_cli/scaffolder.py +159 -0
  30. xtr_cli/subprocess_runner.py +47 -0
  31. xtr_cli/template.py +178 -0
  32. xtr_cli/template_catalog.py +62 -0
  33. xtr_cli/templates/__init__.py +10 -0
  34. xtr_cli/templates/cli/files/README.md.tmpl +44 -0
  35. xtr_cli/templates/cli/files/app/__init__.py.tmpl +5 -0
  36. xtr_cli/templates/cli/files/app/__main__.py.tmpl +23 -0
  37. xtr_cli/templates/cli/files/app/command/__init__.py.tmpl +5 -0
  38. xtr_cli/templates/cli/files/app/command/hello_command.py.tmpl +19 -0
  39. xtr_cli/templates/cli/files/app/kernel.py.tmpl +34 -0
  40. xtr_cli/templates/cli/files/app/py.typed.tmpl +0 -0
  41. xtr_cli/templates/cli/files/gitignore.tmpl +14 -0
  42. xtr_cli/templates/cli/files/pyproject.toml.tmpl +59 -0
  43. xtr_cli/templates/cli/files/python-version.tmpl +1 -0
  44. xtr_cli/templates/cli/files/tests/__init__.py.tmpl +0 -0
  45. xtr_cli/templates/cli/files/tests/test_kernel.py.tmpl +9 -0
  46. xtr_cli/templates/cli/template.toml +34 -0
  47. xtr_cli/templates/webapp/files/README.md.tmpl +49 -0
  48. xtr_cli/templates/webapp/files/app/__init__.py.tmpl +5 -0
  49. xtr_cli/templates/webapp/files/app/__main__.py.tmpl +23 -0
  50. xtr_cli/templates/webapp/files/app/command/__init__.py.tmpl +5 -0
  51. xtr_cli/templates/webapp/files/app/command/hello_command.py.tmpl +19 -0
  52. xtr_cli/templates/webapp/files/app/config/__init__.py.tmpl +5 -0
  53. xtr_cli/templates/webapp/files/app/config/http_kernel.py.tmpl +19 -0
  54. xtr_cli/templates/webapp/files/app/kernel.py.tmpl +37 -0
  55. xtr_cli/templates/webapp/files/app/py.typed.tmpl +0 -0
  56. xtr_cli/templates/webapp/files/app/web/__init__.py.tmpl +5 -0
  57. xtr_cli/templates/webapp/files/app/web/__main__.py.tmpl +42 -0
  58. xtr_cli/templates/webapp/files/app/web/app.py.tmpl +23 -0
  59. xtr_cli/templates/webapp/files/app/web/routes.py.tmpl +15 -0
  60. xtr_cli/templates/webapp/files/gitignore.tmpl +14 -0
  61. xtr_cli/templates/webapp/files/pyproject.toml.tmpl +60 -0
  62. xtr_cli/templates/webapp/files/python-version.tmpl +1 -0
  63. xtr_cli/templates/webapp/files/tests/__init__.py.tmpl +0 -0
  64. xtr_cli/templates/webapp/files/tests/test_kernel.py.tmpl +9 -0
  65. xtr_cli/templates/webapp/files/tests/test_web.py.tmpl +28 -0
  66. xtr_cli/templates/webapp/template.toml +45 -0
  67. xtr_cli/web_entry_point.py +76 -0
  68. xtr_cli/web_entry_point_finder.py +194 -0
  69. xtr_cli-4.0.2.dist-info/METADATA +307 -0
  70. xtr_cli-4.0.2.dist-info/RECORD +73 -0
  71. xtr_cli-4.0.2.dist-info/WHEEL +4 -0
  72. xtr_cli-4.0.2.dist-info/entry_points.txt +4 -0
  73. xtr_cli-4.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,108 @@
1
+ """ProjectQuestions: what the command line left out, asked of whoever is at the terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Final, final
7
+
8
+ from xtr_console import escape
9
+
10
+ from .exception import UnknownAddOnError
11
+
12
+ if TYPE_CHECKING:
13
+ from xtr_console import ConsoleStyle
14
+
15
+ from .add_on import AddOn
16
+ from .add_on_catalog import AddOnCatalog
17
+ from .add_on_choice import AddOnChoice
18
+ from .template_catalog import TemplateCatalog
19
+
20
+ __all__ = ["DEFAULT_TEMPLATE", "ProjectQuestions"]
21
+
22
+ DEFAULT_TEMPLATE: Final = "cli"
23
+ """The template a project gets when none is asked for and no question may be asked."""
24
+
25
+
26
+ @final
27
+ class ProjectQuestions:
28
+ """Asks only for what was not given, and never when questions are off.
29
+
30
+ Every question has an option that answers it, and an option given is never
31
+ asked again. With ``-n`` every question takes its default unasked — the
32
+ ``cli`` template and no add-ons — so a script or a CI job never waits on a
33
+ prompt. A wrong answer is reported and the question asked again.
34
+ """
35
+
36
+ __slots__ = ("_add_ons", "_io", "_templates")
37
+
38
+ def __init__(self, io: ConsoleStyle, templates: TemplateCatalog, add_ons: AddOnCatalog) -> None:
39
+ """Ask through ``io``, offering what ``templates`` and ``add_ons`` hold."""
40
+ self._io = io
41
+ self._templates = templates
42
+ self._add_ons = add_ons
43
+
44
+ def directory(self, given: Path | None) -> Path | None:
45
+ """Return the directory given, or the one answered; ``None`` when there is neither."""
46
+ if given is not None:
47
+ return given
48
+ answer = self._io.ask("Project directory").strip()
49
+ return Path(answer) if answer else None
50
+
51
+ def template(self, given: str | None) -> str:
52
+ """Return the template given, or the shipped one chosen."""
53
+ if given is not None:
54
+ return given
55
+ names = self._templates.names()
56
+ if self._io.interactive:
57
+ self._io.listing(
58
+ escape(f"{name} — {self._templates.load(name).summary}") for name in names
59
+ )
60
+ default = DEFAULT_TEMPLATE if DEFAULT_TEMPLATE in names else names[0]
61
+ return self._io.ask("Template", default, choices=names)
62
+
63
+ def add_ons(self, given: str | None, template: str) -> tuple[AddOnChoice, ...]:
64
+ """Return the add-ons given, or the ones chosen, variants asked for each that has them.
65
+
66
+ Raises:
67
+ UnknownAddOnError: When ``given`` names an add-on that cannot be
68
+ added; a wrong answer is asked again instead.
69
+ """
70
+ if given is not None:
71
+ return self._add_ons.parse(given, template)
72
+ available = self._add_ons.available(template)
73
+ if not self._io.interactive or not available:
74
+ return ()
75
+ self._io.listing(escape(f"{add_on.name} — {add_on.summary}") for add_on in available)
76
+ while True:
77
+ answer = self._io.ask("Add-ons, comma-separated (empty for none)")
78
+ try:
79
+ chosen = self._add_ons.parse(answer, template)
80
+ except UnknownAddOnError as error:
81
+ self._io.error(escape(str(error)))
82
+ continue
83
+ spelled = {
84
+ token.partition(":")[0].strip() for token in answer.split(",") if ":" in token
85
+ }
86
+ return tuple(
87
+ choice
88
+ if not choice.add_on.variants or choice.add_on.name in spelled
89
+ else self._variants(choice.add_on)
90
+ for choice in chosen
91
+ )
92
+
93
+ def confirm(self) -> bool:
94
+ """Ask whether to go ahead; yes when questions are off."""
95
+ return self._io.confirm("Create it?", default=True)
96
+
97
+ def _variants(self, add_on: AddOn) -> AddOnChoice:
98
+ """Ask which of ``add_on``'s variants to install, until the answer is one it offers."""
99
+ question = (
100
+ f"{add_on.variant_label.capitalize()} for {add_on.name} — "
101
+ f"{', '.join(add_on.variants)}, joined with +"
102
+ )
103
+ while True:
104
+ answer = self._io.ask(question, "+".join(add_on.default_variants))
105
+ try:
106
+ return self._add_ons.choose(add_on, answer)
107
+ except UnknownAddOnError as error:
108
+ self._io.error(escape(str(error)))
@@ -0,0 +1,89 @@
1
+ """ProjectSettings: everything decided about a new project before anything is written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, final
9
+
10
+ if TYPE_CHECKING:
11
+ from .add_on_choice import AddOnChoice
12
+ from .project_name import ProjectName
13
+ from .template import Template
14
+
15
+ __all__ = ["ProjectSettings"]
16
+
17
+
18
+ @final
19
+ @dataclass(frozen=True, slots=True)
20
+ class ProjectSettings:
21
+ """The answers a project is made from — from the command line, the questions, or both.
22
+
23
+ Attributes:
24
+ command: The command that makes it, ``new`` or ``init``, for :meth:`command_line`.
25
+ directory: Where the project is made, absolute.
26
+ name: The project's name.
27
+ template: The template it is made from.
28
+ template_source: The template as it was asked for: a shipped name or a directory.
29
+ add_ons: The add-ons it starts with.
30
+ python: The ``3.<minor>`` version it is pinned to.
31
+ name_given: Whether the name was asked for rather than read off the directory.
32
+ git: Whether a git repository is initialised in it.
33
+ local_packages: A directory of xtr package sources to install from, editable,
34
+ instead of the package index.
35
+ """
36
+
37
+ command: str
38
+ directory: Path
39
+ name: ProjectName
40
+ template: Template
41
+ template_source: str
42
+ add_ons: tuple[AddOnChoice, ...]
43
+ python: str
44
+ name_given: bool = False
45
+ git: bool = True
46
+ local_packages: Path | None = None
47
+
48
+ def values(self) -> dict[str, str]:
49
+ """Return what the template's placeholders are filled with.
50
+
51
+ ``minor`` is the part of the Python version after ``3.``, so a tool
52
+ naming versions as ``py313`` can be given ``py3${minor}``.
53
+ """
54
+ return {
55
+ "name": self.name.distribution,
56
+ "app": self.name.app,
57
+ "python": self.python,
58
+ "minor": self.python.partition(".")[2],
59
+ }
60
+
61
+ def command_line(self) -> str:
62
+ """Return the command making this same project with no question asked.
63
+
64
+ Printed before anything is written, so an answered run can be repeated
65
+ in a script or a CI job, or pasted into documentation.
66
+ """
67
+ parts = ["uvx", "xtr-cli", self.command]
68
+ if self.command == "new":
69
+ parts.append(_shown(self.directory))
70
+ parts.append(f"--template={self.template_source}")
71
+ if self.name_given:
72
+ parts.append(f"--name={self.name.distribution}")
73
+ if self.add_ons:
74
+ parts.append(f"--with={','.join(choice.spelling for choice in self.add_ons)}")
75
+ parts.append(f"--python={self.python}")
76
+ if not self.git:
77
+ parts.append("--no-git")
78
+ if self.local_packages is not None:
79
+ parts.append(f"--local-packages={self.local_packages}")
80
+ parts.append("-n")
81
+ return shlex.join(parts)
82
+
83
+
84
+ def _shown(directory: Path) -> str:
85
+ """Return ``directory`` relative to the working directory when it lies under it."""
86
+ try:
87
+ return str(directory.relative_to(Path.cwd()))
88
+ except ValueError:
89
+ return str(directory)
xtr_cli/py.typed ADDED
File without changes
@@ -0,0 +1,41 @@
1
+ """ScaffoldPlan: the files a new project gets and the commands that finish it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, final
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ __all__ = ["ScaffoldPlan"]
13
+
14
+
15
+ @final
16
+ @dataclass(frozen=True, slots=True)
17
+ class ScaffoldPlan:
18
+ """A project worked out in full and not yet made.
19
+
20
+ Planning everything first means a dry run and a real one share one code
21
+ path, and a template that cannot be rendered fails before the directory is
22
+ touched.
23
+
24
+ Attributes:
25
+ directory: Where the project is made.
26
+ files: Each path in the project, ``/``-separated, with the text written there.
27
+ commands: The commands run in the project once its files are written, in order.
28
+ next_steps: What to try once it is made.
29
+ """
30
+
31
+ directory: Path
32
+ files: tuple[tuple[str, str], ...]
33
+ commands: tuple[tuple[str, ...], ...]
34
+ next_steps: tuple[str, ...]
35
+
36
+ def render(self) -> tuple[str, ...]:
37
+ """Return the plan as the lines a dry run prints: one per file, one per command."""
38
+ return (
39
+ *(f"write {path}" for path, _ in self.files),
40
+ *(f"run {shlex.join(argv)}" for argv in self.commands),
41
+ )
xtr_cli/scaffolder.py ADDED
@@ -0,0 +1,159 @@
1
+ """Scaffolder: plans a new project from its settings, then makes it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shlex
8
+ from typing import TYPE_CHECKING, final
9
+
10
+ from .exception import DirectoryNotEmptyError, StepFailedError
11
+ from .scaffold_plan import ScaffoldPlan
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable, Iterable
15
+ from pathlib import Path
16
+
17
+ from .process_runner_interface import ProcessRunnerInterface
18
+ from .project_settings import ProjectSettings
19
+
20
+ __all__ = ["Scaffolder"]
21
+
22
+ _PYPROJECT = "pyproject.toml"
23
+ _RECIPES = "xtr-recipes"
24
+ # Metadata a directory may already hold and still count as empty.
25
+ _IGNORED = frozenset({".git", ".hg", ".idea", ".vscode", ".DS_Store"})
26
+ # A release version: what the xtr requirements are pinned to the major of.
27
+ _RELEASE = re.compile(r"^(?P<major>\d+)\.\d+\.\d+$")
28
+ # A requirement already carrying a version, a URL or a marker is left as written.
29
+ _SPECIFIED = re.compile(r"[<>=!~@;]")
30
+ _NAME_END = re.compile(r"[\s\[<>=!~@;]")
31
+
32
+
33
+ @final
34
+ class Scaffolder:
35
+ """Turns settings into a plan, and a plan into a project.
36
+
37
+ Making a project is writing the template's files, then handing over to
38
+ ``uv``: ``uv add`` installs the requirements and locks them, and the
39
+ project's own ``xtr-recipes recipes:sync`` applies each installed
40
+ package's recipe — the bundle list, the ``.env`` and ignore blocks — with
41
+ the versions the project actually installed.
42
+
43
+ Every xtr package moves with one shared version, so an unversioned xtr
44
+ requirement is pinned to this tool's major version: a template is always
45
+ installed with the packages it was written for.
46
+ """
47
+
48
+ __slots__ = ("_runner", "_version")
49
+
50
+ def __init__(self, runner: ProcessRunnerInterface, version: str) -> None:
51
+ """Run commands through ``runner``; pin the xtr requirements to ``version``'s major."""
52
+ self._runner = runner
53
+ self._version = version
54
+
55
+ @staticmethod
56
+ def check_directory(directory: Path) -> None:
57
+ """Refuse a directory that is a file, or holds anything but metadata.
58
+
59
+ Raises:
60
+ DirectoryNotEmptyError: When ``directory`` is in the way.
61
+ """
62
+ if not directory.exists():
63
+ return
64
+ if not directory.is_dir():
65
+ raise DirectoryNotEmptyError(directory, (directory.name,))
66
+ found = tuple(sorted(entry.name for entry in directory.iterdir()))
67
+ in_the_way = tuple(name for name in found if name not in _IGNORED)
68
+ if in_the_way:
69
+ raise DirectoryNotEmptyError(directory, in_the_way)
70
+
71
+ def plan(self, settings: ProjectSettings) -> ScaffoldPlan:
72
+ """Work the project out in full, writing nothing.
73
+
74
+ Raises:
75
+ InvalidTemplateError: When the template cannot be rendered.
76
+ """
77
+ values = settings.values()
78
+ template = settings.template
79
+ files = template.render(values)
80
+ if settings.local_packages is not None:
81
+ sources = _sources(settings.local_packages)
82
+ files = tuple(
83
+ (path, text + sources if path == _PYPROJECT else text) for path, text in files
84
+ )
85
+ dependencies = self._pinned(
86
+ (*template.dependencies, *(choice.requirement for choice in settings.add_ons))
87
+ )
88
+ dev_dependencies = self._pinned(template.dev_dependencies)
89
+ commands: list[tuple[str, ...]] = []
90
+ if dependencies:
91
+ commands.append(("uv", "add", "--", *dependencies))
92
+ if dev_dependencies:
93
+ commands.append(("uv", "add", "--dev", "--", *dev_dependencies))
94
+ if _RECIPES in {_name(each) for each in (*dependencies, *dev_dependencies)}:
95
+ commands.append(("uv", "run", _RECIPES, "recipes:sync"))
96
+ if settings.git and not (settings.directory / ".git").exists():
97
+ commands.append(("git", "init", "--quiet"))
98
+ return ScaffoldPlan(
99
+ directory=settings.directory,
100
+ files=files,
101
+ commands=tuple(commands),
102
+ next_steps=template.steps(values),
103
+ )
104
+
105
+ def apply(self, plan: ScaffoldPlan, report: Callable[[str], None]) -> None:
106
+ """Make the project ``plan`` describes, reporting each step as it starts.
107
+
108
+ Raises:
109
+ DirectoryNotEmptyError: When the directory filled up since it was
110
+ checked.
111
+ StepFailedError: When a command fails; the commands after it are
112
+ not run, and what was made so far is left in place.
113
+ """
114
+ self.check_directory(plan.directory)
115
+ for path, text in plan.files:
116
+ report(f"write {path}")
117
+ target = plan.directory.joinpath(*path.split("/"))
118
+ target.parent.mkdir(parents=True, exist_ok=True)
119
+ _ = target.write_text(text, encoding="utf-8")
120
+ for argv in plan.commands:
121
+ report(f"run {shlex.join(argv)}")
122
+ code = self._runner.run(argv, plan.directory)
123
+ if code != 0:
124
+ raise StepFailedError(argv, plan.directory, code)
125
+
126
+ def _pinned(self, requirements: Iterable[str]) -> tuple[str, ...]:
127
+ """Pin each unversioned xtr requirement to this tool's major version."""
128
+ release = _RELEASE.match(self._version)
129
+ if release is None:
130
+ return tuple(requirements)
131
+ major = int(release.group("major"))
132
+ return tuple(
133
+ f"{requirement}>={major}.0,<{major + 1}"
134
+ if _name(requirement).startswith("xtr-") and not _SPECIFIED.search(requirement)
135
+ else requirement
136
+ for requirement in requirements
137
+ )
138
+
139
+
140
+ def _name(requirement: str) -> str:
141
+ """Return the distribution a requirement names, without extras or version."""
142
+ stripped = requirement.strip()
143
+ end = _NAME_END.search(stripped)
144
+ return (stripped[: end.start()] if end is not None else stripped).lower()
145
+
146
+
147
+ def _sources(local_packages: Path) -> str:
148
+ """Return a ``[tool.uv.sources]`` table installing every package found there, editable.
149
+
150
+ Every package is listed, not only the ones the project names: a source in
151
+ the project's own manifest applies to the packages it pulls in as well,
152
+ so a sibling that is not yet published still resolves.
153
+ """
154
+ root = local_packages.expanduser().resolve()
155
+ lines: list[str] = []
156
+ for manifest in sorted(root.glob(f"xtr-*/{_PYPROJECT}")):
157
+ path = json.dumps(manifest.parent.as_posix())
158
+ lines.append(f"{manifest.parent.name} = {{ path = {path}, editable = true }}")
159
+ return "\n[tool.uv.sources]\n" + "".join(f"{line}\n" for line in lines)
@@ -0,0 +1,47 @@
1
+ """SubprocessRunner: the commands this tool runs, as real processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from typing import TYPE_CHECKING, Final, final
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+
13
+ __all__ = ["NOT_FOUND", "SubprocessRunner"]
14
+
15
+ NOT_FOUND: Final = 127
16
+ """The code a command that is not on the ``PATH`` ends with, as a shell reports it."""
17
+
18
+
19
+ @final
20
+ class SubprocessRunner:
21
+ """Starts each command as a child process, attached to this one's terminal.
22
+
23
+ ``VIRTUAL_ENV`` is left out of the child's environment: the new project
24
+ always works in its own ``.venv``, and an environment active in the shell
25
+ — the one this tool runs from, say — would only make ``uv`` warn that it
26
+ is being ignored.
27
+
28
+ Ctrl-C reaches the child as well as this process, since both share the
29
+ terminal. The child is then waited for rather than killed, so a server
30
+ finishes shutting down — its application's shutdown hooks included — before
31
+ this process ends; a second Ctrl-C stops waiting.
32
+ """
33
+
34
+ __slots__ = ()
35
+
36
+ def run(self, argv: Sequence[str], cwd: Path, env: Mapping[str, str] | None = None) -> int:
37
+ """Run ``argv`` in ``cwd`` and return its exit code; ``127`` when it is not found."""
38
+ environment = {key: value for key, value in os.environ.items() if key != "VIRTUAL_ENV"}
39
+ environment.update(env or {})
40
+ try:
41
+ process = subprocess.Popen(list(argv), cwd=cwd, env=environment) # noqa: S603 — argv is built by a command.
42
+ except FileNotFoundError:
43
+ return NOT_FOUND
44
+ try:
45
+ return process.wait()
46
+ except KeyboardInterrupt:
47
+ return process.wait()
xtr_cli/template.py ADDED
@@ -0,0 +1,178 @@
1
+ """Template: the files a project starts with, and the packages it is made of."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+ from string import Template as Placeholders
9
+ from typing import TYPE_CHECKING, final
10
+
11
+ from .exception import InvalidTemplateError
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+ from importlib.resources.abc import Traversable
16
+
17
+ __all__ = ["MANIFEST", "Template"]
18
+
19
+ MANIFEST = "template.toml"
20
+ """The file that makes a directory a template."""
21
+
22
+ _TABLES = frozenset({"summary", "dependencies", "files", "notes"})
23
+ _DEPENDENCY_KEYS = frozenset({"packages", "dev"})
24
+ _NOTE_KEYS = frozenset({"next"})
25
+
26
+
27
+ @final
28
+ @dataclass(frozen=True, slots=True)
29
+ class Template:
30
+ """A project template, read from a directory holding a ``template.toml``.
31
+
32
+ A template writes only what no package's recipe can write: the project's
33
+ manifest, its kernel and its entry points. The bundle list, the ``.env``
34
+ file and the ignore lines are left to the recipes of the packages it names,
35
+ applied once they are installed — so a new project is already in the state
36
+ ``recipes:sync --check`` expects.
37
+
38
+ The manifest::
39
+
40
+ summary = "A console application."
41
+
42
+ [dependencies]
43
+ packages = ["xtr-console[di]"] # installed with `uv add`
44
+ dev = ["xtr-recipes"] # installed with `uv add --dev`
45
+
46
+ [files]
47
+ # destination in the project = template under this directory
48
+ "src/${app}/kernel.py" = "files/kernel.py.tmpl"
49
+
50
+ [notes]
51
+ next = ["uv run ${name} list"] # printed once the project is made
52
+
53
+ Destinations, file contents and notes substitute ``${name}`` (the
54
+ distribution), ``${app}`` (the import name), ``${python}`` (``3.13``) and
55
+ ``${minor}`` (``13``) with :meth:`string.Template.substitute`; a literal
56
+ ``$`` is written ``$$``.
57
+
58
+ Attributes:
59
+ name: The template's name — the name of its directory.
60
+ summary: One line on what it makes.
61
+ dependencies: The requirements every project made from it starts with.
62
+ dev_dependencies: The development requirements it starts with.
63
+ files: Each destination pattern mapped to the template text written there.
64
+ next_steps: Commands to try once the project is made.
65
+ """
66
+
67
+ name: str
68
+ summary: str
69
+ dependencies: tuple[str, ...]
70
+ dev_dependencies: tuple[str, ...]
71
+ files: tuple[tuple[str, str], ...]
72
+ next_steps: tuple[str, ...]
73
+
74
+ @classmethod
75
+ def load(cls, root: Traversable, name: str) -> Template:
76
+ """Read the template in ``root``, called ``name``.
77
+
78
+ Raises:
79
+ InvalidTemplateError: When the manifest does not parse, holds an
80
+ unknown table or key or a value of the wrong type, or names a
81
+ template file that is not there.
82
+ """
83
+ try:
84
+ data = tomllib.loads((root / MANIFEST).read_text(encoding="utf-8"))
85
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as error:
86
+ raise InvalidTemplateError(name, f"{MANIFEST} cannot be read: {error}") from error
87
+ _only(name, "the manifest", data, _TABLES)
88
+ summary = data.get("summary", "")
89
+ if not isinstance(summary, str):
90
+ raise InvalidTemplateError(name, "summary must be a string")
91
+ dependencies = _table(name, data, "dependencies", _DEPENDENCY_KEYS)
92
+ notes = _table(name, data, "notes", _NOTE_KEYS)
93
+ return cls(
94
+ name=name,
95
+ summary=summary,
96
+ dependencies=_strings(name, dependencies, "dependencies.packages"),
97
+ dev_dependencies=_strings(name, dependencies, "dependencies.dev"),
98
+ files=_files(name, root, data),
99
+ next_steps=_strings(name, notes, "notes.next"),
100
+ )
101
+
102
+ def render(self, values: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
103
+ """Return every file this template writes, as ``(path, text)``, with ``values`` in.
104
+
105
+ Raises:
106
+ InvalidTemplateError: When a destination or a file holds a
107
+ placeholder ``values`` does not have, or a destination leaves
108
+ the project directory.
109
+ """
110
+ rendered: list[tuple[str, str]] = []
111
+ for pattern, text in self.files:
112
+ destination = self._substitute(pattern, values, f"destination {pattern!r}")
113
+ path = PurePosixPath(destination)
114
+ if path.is_absolute() or ".." in path.parts:
115
+ raise InvalidTemplateError(self.name, f"{destination!r} leaves the project")
116
+ rendered.append((str(path), self._substitute(text, values, f"file {pattern!r}")))
117
+ return tuple(rendered)
118
+
119
+ def steps(self, values: Mapping[str, str]) -> tuple[str, ...]:
120
+ """Return the next steps with ``values`` in.
121
+
122
+ Raises:
123
+ InvalidTemplateError: When a step holds a placeholder ``values``
124
+ does not have.
125
+ """
126
+ return tuple(self._substitute(step, values, "notes.next") for step in self.next_steps)
127
+
128
+ def _substitute(self, text: str, values: Mapping[str, str], where: str) -> str:
129
+ try:
130
+ return Placeholders(text).substitute(values)
131
+ except KeyError as error:
132
+ raise InvalidTemplateError(
133
+ self.name, f"{where}: unknown placeholder {error}"
134
+ ) from error
135
+ except ValueError as error:
136
+ raise InvalidTemplateError(self.name, f"{where}: {error}") from error
137
+
138
+
139
+ def _only(name: str, where: str, data: Mapping[str, object], allowed: frozenset[str]) -> None:
140
+ """Refuse a key ``allowed`` does not hold, so a typo is not silently ignored."""
141
+ unknown = sorted(set(data) - allowed)
142
+ if unknown:
143
+ raise InvalidTemplateError(name, f"unknown key {unknown[0]!r} in {where}")
144
+
145
+
146
+ def _table(
147
+ name: str, data: Mapping[str, object], key: str, allowed: frozenset[str]
148
+ ) -> Mapping[str, object]:
149
+ """Return the table ``key``, empty when absent, holding nothing but ``allowed``."""
150
+ table = data.get(key, {})
151
+ if not isinstance(table, dict):
152
+ raise InvalidTemplateError(name, f"{key} must be a table")
153
+ _only(name, f"[{key}]", table, allowed)
154
+ return table
155
+
156
+
157
+ def _strings(name: str, table: Mapping[str, object], dotted: str) -> tuple[str, ...]:
158
+ """Return the list of strings at ``dotted``'s last part, empty when absent."""
159
+ value = table.get(dotted.rpartition(".")[2], [])
160
+ if not isinstance(value, list) or not all(isinstance(each, str) for each in value):
161
+ raise InvalidTemplateError(name, f"{dotted} must be a list of strings")
162
+ return tuple(value)
163
+
164
+
165
+ def _files(name: str, root: Traversable, data: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
166
+ """Return each destination mapped to the text of the template file it names."""
167
+ table = data.get("files", {})
168
+ if not isinstance(table, dict):
169
+ raise InvalidTemplateError(name, "files must be a table")
170
+ files: list[tuple[str, str]] = []
171
+ for destination, source in table.items():
172
+ if not isinstance(source, str):
173
+ raise InvalidTemplateError(name, f"files.{destination!r} must name a file")
174
+ found = root.joinpath(*PurePosixPath(source).parts)
175
+ if ".." in PurePosixPath(source).parts or not found.is_file():
176
+ raise InvalidTemplateError(name, f"files.{destination!r}: no file {source!r}")
177
+ files.append((destination, found.read_text(encoding="utf-8")))
178
+ return tuple(files)