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,189 @@
|
|
|
1
|
+
"""What the commands share: the questions, the scaffolder, and how a run is reported.
|
|
2
|
+
|
|
3
|
+
``project:new`` and ``project:init`` differ only in where the project goes, so
|
|
4
|
+
both hand their options to :func:`scaffold`. It sits beside the
|
|
5
|
+
:mod:`xtr_cli.command` package rather than in it, so a command module can
|
|
6
|
+
import it without the package being imported back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from xtr_console import ExitCode, escape
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .add_on_catalog import AddOnCatalog
|
|
19
|
+
from .exception import CliError, UnsupportedPythonError
|
|
20
|
+
from .project_name import ProjectName
|
|
21
|
+
from .project_questions import ProjectQuestions
|
|
22
|
+
from .project_settings import ProjectSettings
|
|
23
|
+
from .scaffolder import Scaffolder
|
|
24
|
+
from .subprocess_runner import SubprocessRunner
|
|
25
|
+
from .template_catalog import TemplateCatalog
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from xtr_console import ConsoleStyle
|
|
31
|
+
|
|
32
|
+
from .process_runner_interface import ProcessRunnerInterface
|
|
33
|
+
from .scaffold_plan import ScaffoldPlan
|
|
34
|
+
|
|
35
|
+
__all__ = ["build_runner", "build_scaffolder", "default_python", "scaffold"]
|
|
36
|
+
|
|
37
|
+
_PYTHON = re.compile(r"^3\.(?P<minor>\d+)$")
|
|
38
|
+
_OLDEST_MINOR = 11
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_runner() -> ProcessRunnerInterface:
|
|
42
|
+
"""Return what the commands start ``uv``, ``git`` and a server through.
|
|
43
|
+
|
|
44
|
+
The one seam between a command and a real process: a test replaces this
|
|
45
|
+
with a recorder.
|
|
46
|
+
"""
|
|
47
|
+
return SubprocessRunner()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_scaffolder() -> Scaffolder:
|
|
51
|
+
"""Return the scaffolder a command makes its project with.
|
|
52
|
+
|
|
53
|
+
The one seam between a command and the processes it starts: a test
|
|
54
|
+
replaces this with a scaffolder over a recorder, so a project is planned
|
|
55
|
+
and written without installing anything.
|
|
56
|
+
"""
|
|
57
|
+
return Scaffolder(build_runner(), __version__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_python() -> str:
|
|
61
|
+
"""Return the ``3.<minor>`` of the interpreter this tool runs on."""
|
|
62
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scaffold( # noqa: PLR0913 — one keyword per option the two commands share.
|
|
66
|
+
io: ConsoleStyle,
|
|
67
|
+
*,
|
|
68
|
+
command: str,
|
|
69
|
+
directory: Path | None,
|
|
70
|
+
template: str | None,
|
|
71
|
+
name: str | None,
|
|
72
|
+
add_ons: str | None,
|
|
73
|
+
python: str | None,
|
|
74
|
+
git: bool,
|
|
75
|
+
dry_run: bool,
|
|
76
|
+
local_packages: Path | None,
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Settle the project from the options and the answers, then plan it and make it.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Success once the project is made, or printed for a dry run;
|
|
82
|
+
:attr:`~xtr_console.ExitCode.INVALID` when no directory was given;
|
|
83
|
+
:attr:`~xtr_console.ExitCode.FAILURE` when a setting is refused or a
|
|
84
|
+
step fails.
|
|
85
|
+
"""
|
|
86
|
+
questions = ProjectQuestions(io, TemplateCatalog.default(), AddOnCatalog.default())
|
|
87
|
+
try:
|
|
88
|
+
target = questions.directory(directory)
|
|
89
|
+
if target is None:
|
|
90
|
+
io.error("Give the directory to create the project in: xtr new <directory>")
|
|
91
|
+
return ExitCode.INVALID
|
|
92
|
+
settings = _settings(
|
|
93
|
+
questions,
|
|
94
|
+
command=command,
|
|
95
|
+
directory=target.expanduser().resolve(),
|
|
96
|
+
template=template,
|
|
97
|
+
name=name,
|
|
98
|
+
add_ons=add_ons,
|
|
99
|
+
python=python if python is not None else default_python(),
|
|
100
|
+
git=git,
|
|
101
|
+
local_packages=local_packages,
|
|
102
|
+
)
|
|
103
|
+
scaffolder = build_scaffolder()
|
|
104
|
+
plan = scaffolder.plan(settings)
|
|
105
|
+
except CliError as error:
|
|
106
|
+
io.error(escape(str(error)))
|
|
107
|
+
return ExitCode.FAILURE
|
|
108
|
+
_summarise(io, settings)
|
|
109
|
+
if dry_run:
|
|
110
|
+
for line in plan.render():
|
|
111
|
+
io.text(escape(line))
|
|
112
|
+
return ExitCode.SUCCESS
|
|
113
|
+
if not questions.confirm():
|
|
114
|
+
io.warning("Nothing was written.")
|
|
115
|
+
return ExitCode.SUCCESS
|
|
116
|
+
try:
|
|
117
|
+
scaffolder.apply(plan, lambda line: io.text(escape(line)))
|
|
118
|
+
except CliError as error:
|
|
119
|
+
io.error(escape(str(error)))
|
|
120
|
+
return ExitCode.FAILURE
|
|
121
|
+
_finish(io, settings, plan)
|
|
122
|
+
return ExitCode.SUCCESS
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _settings( # noqa: PLR0913 — the settings, one keyword each.
|
|
126
|
+
questions: ProjectQuestions,
|
|
127
|
+
*,
|
|
128
|
+
command: str,
|
|
129
|
+
directory: Path,
|
|
130
|
+
template: str | None,
|
|
131
|
+
name: str | None,
|
|
132
|
+
add_ons: str | None,
|
|
133
|
+
python: str,
|
|
134
|
+
git: bool,
|
|
135
|
+
local_packages: Path | None,
|
|
136
|
+
) -> ProjectSettings:
|
|
137
|
+
"""Settle every setting, asking for what is missing, refusing what cannot be used."""
|
|
138
|
+
Scaffolder.check_directory(directory)
|
|
139
|
+
project_name = ProjectName.parse(name if name is not None else directory.name)
|
|
140
|
+
version = _PYTHON.match(python)
|
|
141
|
+
if version is None or int(version.group("minor")) < _OLDEST_MINOR:
|
|
142
|
+
raise UnsupportedPythonError(python)
|
|
143
|
+
source = questions.template(template)
|
|
144
|
+
chosen = TemplateCatalog.default().load(source)
|
|
145
|
+
return ProjectSettings(
|
|
146
|
+
command=command,
|
|
147
|
+
directory=directory,
|
|
148
|
+
name=project_name,
|
|
149
|
+
template=chosen,
|
|
150
|
+
template_source=source,
|
|
151
|
+
add_ons=questions.add_ons(add_ons, chosen.name),
|
|
152
|
+
python=python,
|
|
153
|
+
name_given=name is not None,
|
|
154
|
+
git=git,
|
|
155
|
+
local_packages=local_packages.expanduser().resolve() if local_packages else None,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _summarise(io: ConsoleStyle, settings: ProjectSettings) -> None:
|
|
160
|
+
"""Print what is about to be made, and the command that makes it with no question."""
|
|
161
|
+
add_ons = ", ".join(
|
|
162
|
+
f"{choice.add_on.name} ({', '.join(choice.variants)})"
|
|
163
|
+
if choice.variants
|
|
164
|
+
else choice.add_on.name
|
|
165
|
+
for choice in settings.add_ons
|
|
166
|
+
)
|
|
167
|
+
io.section(escape(f"{settings.name.distribution} — the {settings.template.name} template"))
|
|
168
|
+
io.listing(
|
|
169
|
+
escape(line)
|
|
170
|
+
for line in (
|
|
171
|
+
f"directory: {settings.directory}",
|
|
172
|
+
f"add-ons: {add_ons or 'none'}",
|
|
173
|
+
f"python: {settings.python}",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
io.text("The same, with no question asked:")
|
|
177
|
+
io.text(escape(f" {settings.command_line()}"))
|
|
178
|
+
io.newline()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _finish(io: ConsoleStyle, settings: ProjectSettings, plan: ScaffoldPlan) -> None:
|
|
182
|
+
"""Say the project is ready, and what to run first."""
|
|
183
|
+
io.success(escape(f"{settings.name.distribution} is ready in {settings.directory}"))
|
|
184
|
+
steps = plan.next_steps
|
|
185
|
+
if settings.command == "new":
|
|
186
|
+
steps = (f"cd {settings.directory}", *steps)
|
|
187
|
+
if steps:
|
|
188
|
+
io.section("Next steps")
|
|
189
|
+
io.listing(escape(step) for step in steps)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Every error this library raises.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`CliError`, so one ``except`` catches anything
|
|
4
|
+
scaffolding a project can go wrong with, and a narrower one handles a single
|
|
5
|
+
cause.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .ambiguous_web_entry_point_error import AmbiguousWebEntryPointError
|
|
11
|
+
from .cli_error import CliError
|
|
12
|
+
from .directory_not_empty_error import DirectoryNotEmptyError
|
|
13
|
+
from .invalid_project_name_error import InvalidProjectNameError
|
|
14
|
+
from .invalid_template_error import InvalidTemplateError
|
|
15
|
+
from .project_not_found_error import ProjectNotFoundError
|
|
16
|
+
from .step_failed_error import StepFailedError
|
|
17
|
+
from .unknown_add_on_error import UnknownAddOnError
|
|
18
|
+
from .unknown_template_error import UnknownTemplateError
|
|
19
|
+
from .unsupported_python_error import UnsupportedPythonError
|
|
20
|
+
from .web_entry_point_not_found_error import WebEntryPointNotFoundError
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AmbiguousWebEntryPointError",
|
|
24
|
+
"CliError",
|
|
25
|
+
"DirectoryNotEmptyError",
|
|
26
|
+
"InvalidProjectNameError",
|
|
27
|
+
"InvalidTemplateError",
|
|
28
|
+
"ProjectNotFoundError",
|
|
29
|
+
"StepFailedError",
|
|
30
|
+
"UnknownAddOnError",
|
|
31
|
+
"UnknownTemplateError",
|
|
32
|
+
"UnsupportedPythonError",
|
|
33
|
+
"WebEntryPointNotFoundError",
|
|
34
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A project with several web entry points, and no way to ask which one to serve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["AmbiguousWebEntryPointError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class AmbiguousWebEntryPointError(CliError):
|
|
14
|
+
"""Several web entry points were found and questions are off.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
candidates: The entry points found, as ``--entrypoint`` takes them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
candidates: tuple[str, ...]
|
|
21
|
+
|
|
22
|
+
def __init__(self, candidates: tuple[str, ...]) -> None:
|
|
23
|
+
"""Record what could have been served."""
|
|
24
|
+
self.candidates = candidates
|
|
25
|
+
super().__init__(
|
|
26
|
+
f"several web entry points ({', '.join(candidates)}): pass --entrypoint to choose one"
|
|
27
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The root every error in this library derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["CliError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliError(Exception):
|
|
9
|
+
"""Base class for every error raised by this library.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything scaffolding a project can go wrong with; catch
|
|
12
|
+
a subclass to handle one cause. Every subclass carries what went wrong as
|
|
13
|
+
typed attributes and composes its own message from them.
|
|
14
|
+
"""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A project directory that already holds something a project would clash with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = ["DirectoryNotEmptyError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class DirectoryNotEmptyError(CliError):
|
|
17
|
+
"""The directory to scaffold into is a file, or already holds files.
|
|
18
|
+
|
|
19
|
+
Nothing is ever written over: a project starts in a directory that is
|
|
20
|
+
missing or holds nothing but version-control and editor metadata.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
directory: The directory asked for.
|
|
24
|
+
found: The entries that are in the way, sorted.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
directory: Path
|
|
28
|
+
found: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
def __init__(self, directory: Path, found: tuple[str, ...]) -> None:
|
|
31
|
+
"""Record the directory and what is already in it."""
|
|
32
|
+
self.directory = directory
|
|
33
|
+
self.found = found
|
|
34
|
+
listed = ", ".join(found[:5]) + (", …" if len(found) > 5 else "") # noqa: PLR2004 — a preview.
|
|
35
|
+
super().__init__(f"{directory} is not empty ({listed}): choose a new directory")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A project name that cannot name both a distribution and an import package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["InvalidProjectNameError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class InvalidProjectNameError(CliError):
|
|
14
|
+
"""The name asked for, or the directory it was read from, cannot name the project.
|
|
15
|
+
|
|
16
|
+
The name becomes the distribution, the console script and — dashes turned
|
|
17
|
+
into underscores — the import package under ``src/``. A name that is not a
|
|
18
|
+
valid identifier once turned, is a keyword, or would shadow a module of the
|
|
19
|
+
standard library would leave an application that cannot import itself.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: The name as it was given.
|
|
23
|
+
reason: Why it cannot be used.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str, reason: str) -> None:
|
|
30
|
+
"""Record the name and why it was refused."""
|
|
31
|
+
self.name = name
|
|
32
|
+
self.reason = reason
|
|
33
|
+
super().__init__(f"{name!r} cannot name a project: {reason}; pass --name to choose another")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A template whose ``template.toml`` cannot be read as one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["InvalidTemplateError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class InvalidTemplateError(CliError):
|
|
14
|
+
"""A ``template.toml`` that does not parse, or holds a key or value it may not.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
template: The template's name — its directory's name.
|
|
18
|
+
reason: What is wrong, naming the key at fault.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
template: str
|
|
22
|
+
reason: str
|
|
23
|
+
|
|
24
|
+
def __init__(self, template: str, reason: str) -> None:
|
|
25
|
+
"""Record the template and what is wrong with it."""
|
|
26
|
+
self.template = template
|
|
27
|
+
self.reason = reason
|
|
28
|
+
super().__init__(f"template {template!r}: {reason}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""No project at or above the directory a command was run from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = ["ProjectNotFoundError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class ProjectNotFoundError(CliError):
|
|
17
|
+
"""No ``pyproject.toml`` lies at or above the directory searched.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
directory: Where the search started.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
directory: Path
|
|
24
|
+
|
|
25
|
+
def __init__(self, directory: Path) -> None:
|
|
26
|
+
"""Record where the search started."""
|
|
27
|
+
self.directory = directory
|
|
28
|
+
super().__init__(f"no pyproject.toml in {directory} or above it: pass --project-dir")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""A command the scaffold runs — ``uv`` or ``git`` — that exited with a failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = ["StepFailedError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class StepFailedError(CliError):
|
|
17
|
+
"""One of the commands that finish a project failed; the rest were not run.
|
|
18
|
+
|
|
19
|
+
The command has already said why on its own streams. What it left behind is
|
|
20
|
+
kept: the files written so far are the project, and running the remaining
|
|
21
|
+
commands by hand — or deleting the directory — is the way on.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
argv: The command that failed, program and all.
|
|
25
|
+
cwd: Where it ran.
|
|
26
|
+
code: The exit code it ended with.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
argv: tuple[str, ...]
|
|
30
|
+
cwd: Path
|
|
31
|
+
code: int
|
|
32
|
+
|
|
33
|
+
def __init__(self, argv: tuple[str, ...], cwd: Path, code: int) -> None:
|
|
34
|
+
"""Record the command, where it ran, and how it ended."""
|
|
35
|
+
self.argv = argv
|
|
36
|
+
self.cwd = cwd
|
|
37
|
+
self.code = code
|
|
38
|
+
super().__init__(f"`{' '.join(argv)}` exited with {code} in {cwd}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""An add-on this tool does not know, or one the chosen template cannot take."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["UnknownAddOnError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class UnknownAddOnError(CliError):
|
|
14
|
+
"""``--with`` names an add-on, or a variant of one, that cannot be added.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
add_on: The add-on as it was spelled.
|
|
18
|
+
reason: Why it cannot be added, naming what could be chosen instead.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
add_on: str
|
|
22
|
+
reason: str
|
|
23
|
+
|
|
24
|
+
def __init__(self, add_on: str, reason: str) -> None:
|
|
25
|
+
"""Record the add-on and why it was refused."""
|
|
26
|
+
self.add_on = add_on
|
|
27
|
+
self.reason = reason
|
|
28
|
+
super().__init__(f"cannot add {add_on!r}: {reason}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""A template that is neither shipped nor a directory holding one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["UnknownTemplateError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class UnknownTemplateError(CliError):
|
|
14
|
+
"""``--template`` names no shipped template and no directory with a ``template.toml``.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
template: The value given.
|
|
18
|
+
available: The names of the templates that ship with this tool.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
template: str
|
|
22
|
+
available: tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
def __init__(self, template: str, available: tuple[str, ...]) -> None:
|
|
25
|
+
"""Record the value and what could have been chosen instead."""
|
|
26
|
+
self.template = template
|
|
27
|
+
self.available = available
|
|
28
|
+
choices = ", ".join(available)
|
|
29
|
+
super().__init__(f"no template {template!r}: choose {choices}, or a template directory")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A Python version the new project cannot be pinned to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
__all__ = ["UnsupportedPythonError"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class UnsupportedPythonError(CliError):
|
|
14
|
+
"""``--python`` is not a ``3.<minor>`` version the packages support.
|
|
15
|
+
|
|
16
|
+
Every xtr package requires Python 3.11 or later, so a project pinned below
|
|
17
|
+
that could install none of them.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
python: The version as it was given.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
python: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, python: str) -> None:
|
|
26
|
+
"""Record the version that was refused."""
|
|
27
|
+
self.python = python
|
|
28
|
+
super().__init__(f"--python {python!r}: give a version 3.11 or later, such as 3.13")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""A project with no web entry point to serve, or one named that it does not have."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from .cli_error import CliError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = ["WebEntryPointNotFoundError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class WebEntryPointNotFoundError(CliError):
|
|
17
|
+
"""Nothing in the project can be served, or ``--entrypoint`` names nothing it has.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
project_dir: The project searched.
|
|
21
|
+
reason: What was looked for and not found.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
project_dir: Path
|
|
25
|
+
reason: str
|
|
26
|
+
|
|
27
|
+
def __init__(self, project_dir: Path, reason: str) -> None:
|
|
28
|
+
"""Record the project and what it lacks."""
|
|
29
|
+
self.project_dir = project_dir
|
|
30
|
+
self.reason = reason
|
|
31
|
+
super().__init__(f"nothing to serve in {project_dir}: {reason}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""What the commands run ``uv``, ``git`` and an application's server through."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
__all__ = ["ProcessRunnerInterface"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class ProcessRunnerInterface(Protocol):
|
|
16
|
+
"""Runs one command to completion and reports how it ended.
|
|
17
|
+
|
|
18
|
+
Structural on purpose: the default starts a real process, while a test
|
|
19
|
+
supplies a recorder, so a project can be planned and written without
|
|
20
|
+
installing anything.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def run(self, argv: Sequence[str], cwd: Path, env: Mapping[str, str] | None = None) -> int:
|
|
24
|
+
"""Run ``argv`` in ``cwd``, its output going straight to the terminal; return its code.
|
|
25
|
+
|
|
26
|
+
``env`` holds variables set for the command on top of this process's
|
|
27
|
+
own environment.
|
|
28
|
+
"""
|
|
29
|
+
...
|
xtr_cli/project_name.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""ProjectName: the one name a project goes by, as a distribution and as a package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import final
|
|
10
|
+
|
|
11
|
+
from .exception import InvalidProjectNameError
|
|
12
|
+
|
|
13
|
+
__all__ = ["ProjectName"]
|
|
14
|
+
|
|
15
|
+
# Anything but a lowercase letter or a digit separates words; a run of them is one dash.
|
|
16
|
+
_SEPARATORS = re.compile(r"[^a-z0-9]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ProjectName:
|
|
22
|
+
"""A project's name, normalised once and used everywhere it appears.
|
|
23
|
+
|
|
24
|
+
The distribution name is also the console script, so ``uv run <name>``
|
|
25
|
+
reads as the project's own command; the import name is the package under
|
|
26
|
+
``src/`` that the kernel scans.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
distribution: The lowercase, dash-separated name in ``[project].name``.
|
|
30
|
+
app: The import name: the distribution with its dashes as underscores.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
distribution: str
|
|
34
|
+
app: str
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse(cls, text: str) -> ProjectName:
|
|
38
|
+
"""Normalise ``text`` — a ``--name`` or a directory's name — into a project name.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
InvalidProjectNameError: When nothing is left once normalised, or the
|
|
42
|
+
import name it gives is not an identifier, is a keyword, or is
|
|
43
|
+
a module of the standard library the application would shadow.
|
|
44
|
+
"""
|
|
45
|
+
distribution = _SEPARATORS.sub("-", text.strip().lower()).strip("-")
|
|
46
|
+
if not distribution:
|
|
47
|
+
raise InvalidProjectNameError(text, "it holds no letter or digit")
|
|
48
|
+
app = distribution.replace("-", "_")
|
|
49
|
+
if not app.isidentifier():
|
|
50
|
+
raise InvalidProjectNameError(text, f"{app!r} is not a valid import name")
|
|
51
|
+
if keyword.iskeyword(app):
|
|
52
|
+
raise InvalidProjectNameError(text, f"{app!r} is a Python keyword")
|
|
53
|
+
if app in sys.stdlib_module_names:
|
|
54
|
+
raise InvalidProjectNameError(text, f"{app!r} would shadow the standard library")
|
|
55
|
+
return cls(distribution=distribution, app=app)
|