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.
- xtr_cli/__init__.py +79 -0
- xtr_cli/__main__.py +28 -0
- xtr_cli/add_on.py +52 -0
- xtr_cli/add_on_catalog.py +167 -0
- xtr_cli/add_on_choice.py +40 -0
- xtr_cli/command/__init__.py +14 -0
- xtr_cli/command/init_command.py +69 -0
- xtr_cli/command/new_command.py +72 -0
- xtr_cli/command/serve_command.py +114 -0
- xtr_cli/command_support.py +189 -0
- xtr_cli/exception/__init__.py +34 -0
- xtr_cli/exception/ambiguous_web_entry_point_error.py +27 -0
- xtr_cli/exception/cli_error.py +14 -0
- xtr_cli/exception/directory_not_empty_error.py +35 -0
- xtr_cli/exception/invalid_project_name_error.py +33 -0
- xtr_cli/exception/invalid_template_error.py +28 -0
- xtr_cli/exception/project_not_found_error.py +28 -0
- xtr_cli/exception/step_failed_error.py +38 -0
- xtr_cli/exception/unknown_add_on_error.py +28 -0
- xtr_cli/exception/unknown_template_error.py +29 -0
- xtr_cli/exception/unsupported_python_error.py +28 -0
- xtr_cli/exception/web_entry_point_not_found_error.py +31 -0
- xtr_cli/process_runner_interface.py +29 -0
- xtr_cli/project_name.py +55 -0
- xtr_cli/project_questions.py +108 -0
- xtr_cli/project_settings.py +89 -0
- xtr_cli/py.typed +0 -0
- xtr_cli/scaffold_plan.py +41 -0
- xtr_cli/scaffolder.py +159 -0
- xtr_cli/subprocess_runner.py +47 -0
- xtr_cli/template.py +178 -0
- xtr_cli/template_catalog.py +62 -0
- xtr_cli/templates/__init__.py +10 -0
- xtr_cli/templates/cli/files/README.md.tmpl +44 -0
- xtr_cli/templates/cli/files/app/__init__.py.tmpl +5 -0
- xtr_cli/templates/cli/files/app/__main__.py.tmpl +23 -0
- xtr_cli/templates/cli/files/app/command/__init__.py.tmpl +5 -0
- xtr_cli/templates/cli/files/app/command/hello_command.py.tmpl +19 -0
- xtr_cli/templates/cli/files/app/kernel.py.tmpl +34 -0
- xtr_cli/templates/cli/files/app/py.typed.tmpl +0 -0
- xtr_cli/templates/cli/files/gitignore.tmpl +14 -0
- xtr_cli/templates/cli/files/pyproject.toml.tmpl +59 -0
- xtr_cli/templates/cli/files/python-version.tmpl +1 -0
- xtr_cli/templates/cli/files/tests/__init__.py.tmpl +0 -0
- xtr_cli/templates/cli/files/tests/test_kernel.py.tmpl +9 -0
- xtr_cli/templates/cli/template.toml +34 -0
- xtr_cli/templates/webapp/files/README.md.tmpl +49 -0
- xtr_cli/templates/webapp/files/app/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/__main__.py.tmpl +23 -0
- xtr_cli/templates/webapp/files/app/command/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/command/hello_command.py.tmpl +19 -0
- xtr_cli/templates/webapp/files/app/config/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/config/http_kernel.py.tmpl +19 -0
- xtr_cli/templates/webapp/files/app/kernel.py.tmpl +37 -0
- xtr_cli/templates/webapp/files/app/py.typed.tmpl +0 -0
- xtr_cli/templates/webapp/files/app/web/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/web/__main__.py.tmpl +42 -0
- xtr_cli/templates/webapp/files/app/web/app.py.tmpl +23 -0
- xtr_cli/templates/webapp/files/app/web/routes.py.tmpl +15 -0
- xtr_cli/templates/webapp/files/gitignore.tmpl +14 -0
- xtr_cli/templates/webapp/files/pyproject.toml.tmpl +60 -0
- xtr_cli/templates/webapp/files/python-version.tmpl +1 -0
- xtr_cli/templates/webapp/files/tests/__init__.py.tmpl +0 -0
- xtr_cli/templates/webapp/files/tests/test_kernel.py.tmpl +9 -0
- xtr_cli/templates/webapp/files/tests/test_web.py.tmpl +28 -0
- xtr_cli/templates/webapp/template.toml +45 -0
- xtr_cli/web_entry_point.py +76 -0
- xtr_cli/web_entry_point_finder.py +194 -0
- xtr_cli-4.0.2.dist-info/METADATA +307 -0
- xtr_cli-4.0.2.dist-info/RECORD +73 -0
- xtr_cli-4.0.2.dist-info/WHEEL +4 -0
- xtr_cli-4.0.2.dist-info/entry_points.txt +4 -0
- 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
|
xtr_cli/scaffold_plan.py
ADDED
|
@@ -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)
|