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
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())
|
xtr_cli/add_on_choice.py
ADDED
|
@@ -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)]
|