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,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
+ ...
@@ -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)