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
xtr_cli/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Creates a new application on the xtr packages, ready to run, in one command.
2
+
3
+ ``xtr new <directory>`` asks for what it was not told — the template, the
4
+ add-ons — writes the few files no package's recipe can (the project manifest,
5
+ the kernel and the entry points), installs the packages with ``uv``, and lets
6
+ each package's recipe configure itself with ``xtr-recipes recipes:sync``. The
7
+ project it leaves boots, and is already in the state a recipe check expects.
8
+
9
+ This module exposes the pieces that plan and make a project; the commands that
10
+ drive them live in :mod:`xtr_cli.command`, behind the ``xtr`` console script.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ from .add_on import AddOn
18
+ from .add_on_catalog import AddOnCatalog
19
+ from .add_on_choice import AddOnChoice
20
+ from .exception import (
21
+ AmbiguousWebEntryPointError,
22
+ CliError,
23
+ DirectoryNotEmptyError,
24
+ InvalidProjectNameError,
25
+ InvalidTemplateError,
26
+ ProjectNotFoundError,
27
+ StepFailedError,
28
+ UnknownAddOnError,
29
+ UnknownTemplateError,
30
+ UnsupportedPythonError,
31
+ WebEntryPointNotFoundError,
32
+ )
33
+ from .process_runner_interface import ProcessRunnerInterface
34
+ from .project_name import ProjectName
35
+ from .project_questions import ProjectQuestions
36
+ from .project_settings import ProjectSettings
37
+ from .scaffold_plan import ScaffoldPlan
38
+ from .scaffolder import Scaffolder
39
+ from .subprocess_runner import SubprocessRunner
40
+ from .template import Template
41
+ from .template_catalog import TemplateCatalog
42
+ from .web_entry_point import WebEntryPoint
43
+ from .web_entry_point_finder import WebEntryPointFinder
44
+
45
+ try:
46
+ __version__ = version("xtr-cli")
47
+ except PackageNotFoundError: # pragma: no cover
48
+ # Running from a source tree with no installed metadata to read. Having no
49
+ # version is better than refusing to import.
50
+ __version__ = "0+unknown"
51
+
52
+ __all__ = [
53
+ "AddOn",
54
+ "AddOnCatalog",
55
+ "AddOnChoice",
56
+ "AmbiguousWebEntryPointError",
57
+ "CliError",
58
+ "DirectoryNotEmptyError",
59
+ "InvalidProjectNameError",
60
+ "InvalidTemplateError",
61
+ "ProcessRunnerInterface",
62
+ "ProjectName",
63
+ "ProjectNotFoundError",
64
+ "ProjectQuestions",
65
+ "ProjectSettings",
66
+ "ScaffoldPlan",
67
+ "Scaffolder",
68
+ "StepFailedError",
69
+ "SubprocessRunner",
70
+ "Template",
71
+ "TemplateCatalog",
72
+ "UnknownAddOnError",
73
+ "UnknownTemplateError",
74
+ "UnsupportedPythonError",
75
+ "WebEntryPoint",
76
+ "WebEntryPointFinder",
77
+ "WebEntryPointNotFoundError",
78
+ "__version__",
79
+ ]
xtr_cli/__main__.py ADDED
@@ -0,0 +1,28 @@
1
+ """The console entry point: ``uvx xtr-cli <command>``, or ``xtr <command>`` once installed.
2
+
3
+ A console of its own, not an application's kernel: it runs before there is an
4
+ application to boot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from xtr_console import Application
10
+
11
+ from . import (
12
+ __version__,
13
+ command, # noqa: F401 # pyright: ignore[reportUnusedImport] — declares the commands
14
+ )
15
+
16
+ __all__ = ["main"]
17
+
18
+ _DESCRIPTION = "Creates a new xtr application — a console or a web application — and serves it."
19
+
20
+
21
+ def main() -> None:
22
+ """Build the console application and run it, exiting with its code."""
23
+ application = Application(name="xtr", version=__version__, description=_DESCRIPTION)
24
+ raise SystemExit(application.run())
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
xtr_cli/add_on.py ADDED
@@ -0,0 +1,52 @@
1
+ """AddOn: a package a new project can start with, beyond what its template brings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+
11
+ __all__ = ["AddOn"]
12
+
13
+
14
+ @final
15
+ @dataclass(frozen=True, slots=True)
16
+ class AddOn:
17
+ """One entry of the add-on table: a short name standing for a requirement.
18
+
19
+ Choosing an add-on only adds its requirement; the recipe the package ships
20
+ does the rest of the configuring, the way it would for a package added later.
21
+
22
+ Attributes:
23
+ name: What ``--with`` calls it.
24
+ summary: One line on what it brings, shown when the add-ons are offered.
25
+ distribution: The package it installs.
26
+ extras: The extras always installed with it — its bundle's among them.
27
+ variants: The further extras a project picks between, such as database
28
+ drivers; written after the name as ``orm:sqlite+postgres``.
29
+ default_variants: The variants chosen when the name is given alone.
30
+ variant_label: What the variants are, for the question that offers them.
31
+ templates: The templates it fits; empty when it fits every one.
32
+ """
33
+
34
+ name: str
35
+ summary: str
36
+ distribution: str
37
+ extras: tuple[str, ...] = ()
38
+ variants: tuple[str, ...] = ()
39
+ default_variants: tuple[str, ...] = ()
40
+ variant_label: str = "extras"
41
+ templates: tuple[str, ...] = ()
42
+
43
+ def requirement(self, variants: Sequence[str]) -> str:
44
+ """Return the requirement installing this add-on with ``variants``."""
45
+ extras = (*self.extras, *variants)
46
+ if not extras:
47
+ return self.distribution
48
+ return f"{self.distribution}[{','.join(extras)}]"
49
+
50
+ def fits(self, template: str) -> bool:
51
+ """Report whether a project made from ``template`` can take this add-on."""
52
+ return not self.templates or template in self.templates
@@ -0,0 +1,167 @@
1
+ """AddOnCatalog: the add-ons a new project can start with, and how ``--with`` names them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from .add_on import AddOn
8
+ from .add_on_choice import AddOnChoice
9
+ from .exception import UnknownAddOnError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+
14
+ __all__ = ["AddOnCatalog"]
15
+
16
+ _WEBAPP = ("webapp",)
17
+
18
+ _SHIPPED = (
19
+ AddOn(
20
+ "orm",
21
+ "Database engines, sessions per unit of work and versioned migrations.",
22
+ "xtr-orm",
23
+ ("di", "console"),
24
+ ("sqlite", "postgres", "mysql"),
25
+ ("sqlite",),
26
+ "database drivers",
27
+ ),
28
+ AddOn(
29
+ "messenger",
30
+ "A message bus: handlers, middleware, transports and consumers.",
31
+ "xtr-messenger",
32
+ ("di", "console"),
33
+ ("amqp", "taskiq", "pydantic"),
34
+ (),
35
+ "transports and serializers",
36
+ ),
37
+ AddOn(
38
+ "scheduler",
39
+ "Recurring messages on the bus: cron and intervals.",
40
+ "xtr-scheduler",
41
+ ("di", "console", "cron"),
42
+ ),
43
+ AddOn(
44
+ "event-dispatcher",
45
+ "Events dispatched to listeners and subscribers by priority.",
46
+ "xtr-event-dispatcher",
47
+ ("di", "console"),
48
+ ),
49
+ AddOn(
50
+ "cache",
51
+ "Cache pools in memory, in files or in Redis.",
52
+ "xtr-cache",
53
+ ("di", "console"),
54
+ ("redis",),
55
+ (),
56
+ "backends",
57
+ ),
58
+ AddOn("lock", "Locks around shared resources.", "xtr-lock", ("di",), ("redis",), (), "stores"),
59
+ AddOn(
60
+ "rate-limiter",
61
+ "Limits on how often anything may happen.",
62
+ "xtr-rate-limiter",
63
+ ("di", "cache", "lock"),
64
+ ("redis",),
65
+ (),
66
+ "stores",
67
+ ),
68
+ AddOn(
69
+ "storage",
70
+ "Files on local disk, in memory or in object stores.",
71
+ "xtr-storage",
72
+ ("di",),
73
+ ("s3", "gcs"),
74
+ (),
75
+ "object stores",
76
+ ),
77
+ AddOn(
78
+ "security",
79
+ "Firewalls, authenticators, voters and password hashing.",
80
+ "xtr-security",
81
+ ("console",),
82
+ templates=_WEBAPP,
83
+ ),
84
+ AddOn(
85
+ "jwt",
86
+ "Self-issued JSON Web Tokens: a token manager and a firewall authenticator.",
87
+ "xtr-security-jwt",
88
+ ("console",),
89
+ templates=_WEBAPP,
90
+ ),
91
+ )
92
+
93
+
94
+ @final
95
+ class AddOnCatalog:
96
+ """The add-ons on offer, looked up and parsed by name.
97
+
98
+ ``--with`` is a comma-separated list of names, each optionally followed by
99
+ ``:`` and its variants joined with ``+`` — ``orm:sqlite+postgres,messenger``.
100
+ A name given alone takes the add-on's default variants.
101
+ """
102
+
103
+ __slots__ = ("_add_ons",)
104
+
105
+ def __init__(self, add_ons: Iterable[AddOn]) -> None:
106
+ """Offer ``add_ons``, in the order given."""
107
+ self._add_ons: dict[str, AddOn] = {add_on.name: add_on for add_on in add_ons}
108
+
109
+ @classmethod
110
+ def default(cls) -> AddOnCatalog:
111
+ """Return the catalog of the add-ons this tool ships."""
112
+ return cls(_SHIPPED)
113
+
114
+ def available(self, template: str) -> tuple[AddOn, ...]:
115
+ """Return the add-ons a project made from ``template`` can take, in catalog order."""
116
+ return tuple(add_on for add_on in self._add_ons.values() if add_on.fits(template))
117
+
118
+ def get(self, name: str, template: str) -> AddOn:
119
+ """Return the add-on called ``name``, for a project made from ``template``.
120
+
121
+ Raises:
122
+ UnknownAddOnError: When no add-on has that name, or it does not fit
123
+ the template.
124
+ """
125
+ add_on = self._add_ons.get(name)
126
+ if add_on is None or not add_on.fits(template):
127
+ names = ", ".join(each.name for each in self.available(template))
128
+ raise UnknownAddOnError(name, f"a {template} project can take {names}")
129
+ return add_on
130
+
131
+ def choose(self, add_on: AddOn, variants: str | None) -> AddOnChoice:
132
+ """Return ``add_on`` with the ``+``-separated ``variants`` picked.
133
+
134
+ ``None`` takes the add-on's defaults; an empty string picks none.
135
+
136
+ Raises:
137
+ UnknownAddOnError: When a variant is not one the add-on offers.
138
+ """
139
+ if variants is None:
140
+ return AddOnChoice(add_on, add_on.default_variants)
141
+ picked = {variant.strip() for variant in variants.split("+") if variant.strip()}
142
+ unknown = sorted(picked - set(add_on.variants))
143
+ if unknown:
144
+ offered = ", ".join(add_on.variants) or "none"
145
+ raise UnknownAddOnError(
146
+ f"{add_on.name}:{variants}",
147
+ f"{', '.join(unknown)} is not one of its {add_on.variant_label} ({offered})",
148
+ )
149
+ return AddOnChoice(add_on, tuple(each for each in add_on.variants if each in picked))
150
+
151
+ def parse(self, text: str, template: str) -> tuple[AddOnChoice, ...]:
152
+ """Read a ``--with`` value into the add-ons it chooses, in the order written.
153
+
154
+ Raises:
155
+ UnknownAddOnError: When a name is unknown, does not fit the
156
+ template, is given twice, or names a variant its add-on lacks.
157
+ """
158
+ chosen: dict[str, AddOnChoice] = {}
159
+ for token in (each.strip() for each in text.split(",")):
160
+ if not token:
161
+ continue
162
+ name, separator, variants = token.partition(":")
163
+ add_on = self.get(name.strip(), template)
164
+ if add_on.name in chosen:
165
+ raise UnknownAddOnError(token, "it is chosen twice")
166
+ chosen[add_on.name] = self.choose(add_on, variants if separator else None)
167
+ return tuple(chosen.values())
@@ -0,0 +1,40 @@
1
+ """AddOnChoice: an add-on as a project chose it, variants and all."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ if TYPE_CHECKING:
9
+ from .add_on import AddOn
10
+
11
+ __all__ = ["AddOnChoice"]
12
+
13
+
14
+ @final
15
+ @dataclass(frozen=True, slots=True)
16
+ class AddOnChoice:
17
+ """An add-on and the variants picked for it.
18
+
19
+ Attributes:
20
+ add_on: The add-on chosen.
21
+ variants: The variants picked, in the order the add-on lists them.
22
+ """
23
+
24
+ add_on: AddOn
25
+ variants: tuple[str, ...]
26
+
27
+ @property
28
+ def requirement(self) -> str:
29
+ """The requirement ``uv add`` is given for this choice."""
30
+ return self.add_on.requirement(self.variants)
31
+
32
+ @property
33
+ def spelling(self) -> str:
34
+ """How ``--with`` writes this choice, so a command line can repeat it.
35
+
36
+ The bare name when the variants are the defaults, which it stands for.
37
+ """
38
+ if self.variants == self.add_on.default_variants:
39
+ return self.add_on.name
40
+ return f"{self.add_on.name}:{'+'.join(self.variants)}"
@@ -0,0 +1,14 @@
1
+ """The commands: two that create a project, and one that serves it.
2
+
3
+ Importing this package declares all of them: ``@as_command`` writes to the
4
+ console's registry as each module is read, which is why the entry point imports
5
+ this and nothing else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .init_command import InitCommand
11
+ from .new_command import NewCommand
12
+ from .serve_command import ServeCommand
13
+
14
+ __all__ = ["InitCommand", "NewCommand", "ServeCommand"]
@@ -0,0 +1,69 @@
1
+ """``project:init``: create an application in the current directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, final
7
+
8
+ from xtr_console import ConsoleStyle, Option, as_command
9
+
10
+ from xtr_cli import command_support
11
+
12
+ __all__ = ["InitCommand"]
13
+
14
+
15
+ @as_command("project:init", aliases="init")
16
+ @final
17
+ class InitCommand:
18
+ """Create a new application, from a template, in the current directory.
19
+
20
+ The directory must hold nothing but version-control or editor metadata;
21
+ an existing git repository is kept rather than initialised again.
22
+ """
23
+
24
+ async def __call__( # noqa: PLR0913 — one parameter per option.
25
+ self,
26
+ io: ConsoleStyle,
27
+ *,
28
+ template: str | None = None,
29
+ name: str | None = None,
30
+ add_ons: Annotated[
31
+ str | None,
32
+ Option(
33
+ name="--with",
34
+ help="More packages to start with, comma-separated: orm:sqlite+postgres,messenger.",
35
+ ),
36
+ ] = None,
37
+ python: str | None = None,
38
+ no_git: bool = False,
39
+ dry_run: bool = False,
40
+ local_packages: Path | None = None,
41
+ ) -> int:
42
+ """Create a new application, from a template, in the current directory.
43
+
44
+ Args:
45
+ io: Where the questions are asked and the steps reported.
46
+ template: cli or webapp, or a directory holding a template.toml.
47
+ name: The project's name; the directory's name by default.
48
+ add_ons: The ``--with`` add-ons.
49
+ python: The 3.<minor> version to pin; the one running this tool by default.
50
+ no_git: Do not initialise a git repository.
51
+ dry_run: Print the files and commands, and write nothing.
52
+ local_packages: Install the xtr packages from this directory of
53
+ sources, editable, instead of the package index.
54
+
55
+ Returns:
56
+ The exit code.
57
+ """
58
+ return command_support.scaffold(
59
+ io,
60
+ command="init",
61
+ directory=Path.cwd(),
62
+ template=template,
63
+ name=name,
64
+ add_ons=add_ons,
65
+ python=python,
66
+ git=not no_git,
67
+ dry_run=dry_run,
68
+ local_packages=local_packages,
69
+ )
@@ -0,0 +1,72 @@
1
+ """``project:new``: create an application in a new directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path # noqa: TC003 — the console reads annotations at runtime.
6
+ from typing import Annotated, final
7
+
8
+ from xtr_console import ConsoleStyle, Option, as_command
9
+
10
+ from xtr_cli import command_support
11
+
12
+ __all__ = ["NewCommand"]
13
+
14
+
15
+ @as_command("project:new", aliases="new")
16
+ @final
17
+ class NewCommand:
18
+ """Create a new application, from a template, in a new directory.
19
+
20
+ What is not given is asked for; with ``-n`` nothing is asked and the
21
+ defaults are taken. The packages are installed with ``uv``, and each one's
22
+ recipe is applied, before the command ends.
23
+ """
24
+
25
+ async def __call__( # noqa: PLR0913 — one parameter per option.
26
+ self,
27
+ io: ConsoleStyle,
28
+ directory: Path | None = None,
29
+ *,
30
+ template: str | None = None,
31
+ name: str | None = None,
32
+ add_ons: Annotated[
33
+ str | None,
34
+ Option(
35
+ name="--with",
36
+ help="More packages to start with, comma-separated: orm:sqlite+postgres,messenger.",
37
+ ),
38
+ ] = None,
39
+ python: str | None = None,
40
+ no_git: bool = False,
41
+ dry_run: bool = False,
42
+ local_packages: Path | None = None,
43
+ ) -> int:
44
+ """Create a new application, from a template, in a new directory.
45
+
46
+ Args:
47
+ io: Where the questions are asked and the steps reported.
48
+ directory: Where to create it; it must be missing or empty.
49
+ template: cli or webapp, or a directory holding a template.toml.
50
+ name: The project's name; the directory's name by default.
51
+ add_ons: The ``--with`` add-ons.
52
+ python: The 3.<minor> version to pin; the one running this tool by default.
53
+ no_git: Do not initialise a git repository.
54
+ dry_run: Print the files and commands, and write nothing.
55
+ local_packages: Install the xtr packages from this directory of
56
+ sources, editable, instead of the package index.
57
+
58
+ Returns:
59
+ The exit code.
60
+ """
61
+ return command_support.scaffold(
62
+ io,
63
+ command="new",
64
+ directory=directory,
65
+ template=template,
66
+ name=name,
67
+ add_ons=add_ons,
68
+ python=python,
69
+ git=not no_git,
70
+ dry_run=dry_run,
71
+ local_packages=local_packages,
72
+ )
@@ -0,0 +1,114 @@
1
+ """``server:start``: serve the application the current directory belongs to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from pathlib import Path # noqa: TC003 — the console reads annotations at runtime.
7
+ from typing import TYPE_CHECKING, Annotated, final
8
+
9
+ from xtr_console import ConsoleStyle, ExitCode, Option, Range, as_command, escape
10
+
11
+ from xtr_cli import command_support
12
+ from xtr_cli.exception import AmbiguousWebEntryPointError, CliError, WebEntryPointNotFoundError
13
+ from xtr_cli.subprocess_runner import NOT_FOUND
14
+ from xtr_cli.web_entry_point_finder import WebEntryPointFinder
15
+
16
+ if TYPE_CHECKING:
17
+ from xtr_cli.web_entry_point import WebEntryPoint
18
+
19
+ __all__ = ["ServeCommand"]
20
+
21
+ _NONE_FOUND = (
22
+ "no console script imports a server or is named *-web, and no module defines a "
23
+ "FastAPI(), Starlette() or Litestar() application; pass --entrypoint"
24
+ )
25
+
26
+
27
+ @as_command("server:start", aliases="serve")
28
+ @final
29
+ class ServeCommand:
30
+ """Serve the application the current directory belongs to.
31
+
32
+ The web entry point is found in the project — its web console script, or
33
+ else a module-level ASGI application — and run in the project's own
34
+ environment with ``uv run``. The server restarts on every source change
35
+ unless ``--no-reload`` is given. Ctrl-C stops the server, and waits for the
36
+ application to shut down.
37
+ """
38
+
39
+ async def __call__( # noqa: PLR0913 — one parameter per option.
40
+ self,
41
+ io: ConsoleStyle,
42
+ *,
43
+ project_dir: Path | None = None,
44
+ entrypoint: str | None = None,
45
+ host: str | None = None,
46
+ port: Annotated[
47
+ int | None,
48
+ Option(
49
+ validator=Range(gte=1, lte=65535),
50
+ help="The port to listen on; the entry point's own default when left out.",
51
+ ),
52
+ ] = None,
53
+ reload: Annotated[
54
+ bool,
55
+ Option(
56
+ negative="--no-reload",
57
+ help="Restart the server when a source file changes; --no-reload turns it off.",
58
+ ),
59
+ ] = True,
60
+ dry_run: bool = False,
61
+ ) -> int:
62
+ """Serve the application the current directory belongs to.
63
+
64
+ Args:
65
+ io: Where the choice is asked and the command reported.
66
+ project_dir: The project to serve; the nearest one at or above the
67
+ current directory by default.
68
+ entrypoint: What to serve: a console script, or a module:attribute
69
+ ASGI application. Found in the project when left out.
70
+ host: Where to listen; the entry point's own default when left out.
71
+ port: The port to listen on.
72
+ reload: Restart the server when a source file changes; on by default,
73
+ --no-reload turns it off.
74
+ dry_run: Print the command, and run nothing.
75
+
76
+ Returns:
77
+ The server's exit code, or a failure when there is nothing to serve.
78
+ """
79
+ finder = WebEntryPointFinder()
80
+ try:
81
+ directory = finder.project_dir(project_dir)
82
+ chosen = (
83
+ finder.resolve(directory, entrypoint)
84
+ if entrypoint is not None
85
+ else _choose(io, finder.find(directory), directory)
86
+ )
87
+ except CliError as error:
88
+ io.error(escape(str(error)))
89
+ return ExitCode.FAILURE
90
+ argv, environment = chosen.command(host, port, reload=reload)
91
+ assignments = [f"{key}={shlex.quote(value)}" for key, value in environment.items()]
92
+ io.text(escape(f"Serving {chosen.label} from {directory}"))
93
+ io.text(escape(" " + " ".join((*assignments, shlex.join(argv)))))
94
+ if dry_run:
95
+ return ExitCode.SUCCESS
96
+ code = command_support.build_runner().run(argv, directory, environment)
97
+ if code == NOT_FOUND:
98
+ io.error("uv is not on the PATH: install it from https://docs.astral.sh/uv/")
99
+ return code
100
+
101
+
102
+ def _choose(
103
+ io: ConsoleStyle, candidates: tuple[WebEntryPoint, ...], directory: Path
104
+ ) -> WebEntryPoint:
105
+ """Return the one candidate, or the one picked; with no question possible, refuse."""
106
+ if not candidates:
107
+ raise WebEntryPointNotFoundError(directory, _NONE_FOUND)
108
+ if len(candidates) == 1:
109
+ return candidates[0]
110
+ labels = tuple(candidate.label for candidate in candidates)
111
+ if not io.interactive:
112
+ raise AmbiguousWebEntryPointError(labels)
113
+ picked = io.ask("Web entry point", labels[0], choices=labels)
114
+ return candidates[labels.index(picked)]