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,60 @@
1
+ [project]
2
+ name = "${name}"
3
+ version = "0.1.0"
4
+ description = "A web application on the xtr packages."
5
+ readme = "README.md"
6
+ requires-python = ">=${python}"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ ${name} = "${app}.__main__:main"
11
+ ${name}-web = "${app}.web.__main__:main"
12
+
13
+ [dependency-groups]
14
+ dev = []
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.9.18,<0.10.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.basedpyright]
21
+ typeCheckingMode = "all"
22
+ pythonVersion = "${python}"
23
+ include = ["src", "tests"]
24
+ exclude = ["**/__pycache__", "**/.venv", "var"]
25
+ reportUnusedCallResult = "warning"
26
+
27
+ [tool.ruff]
28
+ target-version = "py3${minor}"
29
+ line-length = 100
30
+ src = ["src", "tests"]
31
+
32
+ [tool.ruff.lint]
33
+ select = ["ALL"]
34
+ ignore = [
35
+ # Formatter conflicts.
36
+ "COM812",
37
+ "ISC001",
38
+ # Mutually exclusive docstring conventions; keep the modern one.
39
+ "D203",
40
+ "D213",
41
+ "CPY001",
42
+ # The container and the console read annotations at runtime: an import used only in
43
+ # an annotation is still needed at runtime, so none may move under TYPE_CHECKING.
44
+ "TC001",
45
+ "TC002",
46
+ "TC003",
47
+ ]
48
+
49
+ [tool.ruff.lint.per-file-ignores]
50
+ "tests/**/*.py" = ["S101", "D"]
51
+
52
+ [tool.ruff.lint.pydocstyle]
53
+ convention = "google"
54
+
55
+ [tool.ty.src]
56
+ include = ["src", "tests"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = ["-ra", "--strict-config", "--strict-markers"]
@@ -0,0 +1 @@
1
+ ${python}
File without changes
@@ -0,0 +1,9 @@
1
+ """The application's kernel builds, with every listed bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ${app}.kernel import kernel
6
+
7
+
8
+ def test_the_kernel_builds_in_the_test_environment() -> None:
9
+ _ = kernel.with_env("test").build()
@@ -0,0 +1,28 @@
1
+ """The web application answers, served from its kernel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from http import HTTPStatus
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from ${app}.web.app import app
11
+
12
+ pytestmark = pytest.mark.anyio
13
+
14
+
15
+ @pytest.fixture
16
+ def anyio_backend() -> str:
17
+ return "asyncio"
18
+
19
+
20
+ async def test_the_home_page_answers() -> None:
21
+ # The lifespan is what builds and boots the kernel the requests are served from.
22
+ async with app.router.lifespan_context(app):
23
+ transport = httpx.ASGITransport(app=app)
24
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
25
+ response = await client.get("/")
26
+
27
+ assert response.status_code == HTTPStatus.OK
28
+ assert response.json() == {"status": "ok"}
@@ -0,0 +1,45 @@
1
+ # A web application: the cli template, plus a FastAPI application served from the
2
+ # same kernel through the http-kernel request lifecycle.
3
+ summary = "A web application: FastAPI served from the kernel, with the console beside it."
4
+
5
+ [dependencies]
6
+ packages = [
7
+ "xtr-dependency-injection[fastapi]",
8
+ "xtr-console[di]",
9
+ "xtr-dotenv[di,console]",
10
+ "xtr-logging[di]",
11
+ "xtr-http-kernel[logging,console]",
12
+ "fastapi",
13
+ "uvicorn",
14
+ ]
15
+ dev = ["xtr-recipes", "pytest", "httpx", "ruff", "basedpyright", "ty"]
16
+
17
+ # Only what no package's recipe writes. The bundle list, .env and the ignore
18
+ # blocks are the recipes' to write, once the packages are installed.
19
+ [files]
20
+ "pyproject.toml" = "files/pyproject.toml.tmpl"
21
+ ".python-version" = "files/python-version.tmpl"
22
+ ".gitignore" = "files/gitignore.tmpl"
23
+ "README.md" = "files/README.md.tmpl"
24
+ "src/${app}/__init__.py" = "files/app/__init__.py.tmpl"
25
+ "src/${app}/__main__.py" = "files/app/__main__.py.tmpl"
26
+ "src/${app}/kernel.py" = "files/app/kernel.py.tmpl"
27
+ "src/${app}/py.typed" = "files/app/py.typed.tmpl"
28
+ "src/${app}/command/__init__.py" = "files/app/command/__init__.py.tmpl"
29
+ "src/${app}/command/hello_command.py" = "files/app/command/hello_command.py.tmpl"
30
+ "src/${app}/config/__init__.py" = "files/app/config/__init__.py.tmpl"
31
+ "src/${app}/config/http_kernel.py" = "files/app/config/http_kernel.py.tmpl"
32
+ "src/${app}/web/__init__.py" = "files/app/web/__init__.py.tmpl"
33
+ "src/${app}/web/__main__.py" = "files/app/web/__main__.py.tmpl"
34
+ "src/${app}/web/app.py" = "files/app/web/app.py.tmpl"
35
+ "src/${app}/web/routes.py" = "files/app/web/routes.py.tmpl"
36
+ "tests/__init__.py" = "files/tests/__init__.py.tmpl"
37
+ "tests/test_kernel.py" = "files/tests/test_kernel.py.tmpl"
38
+ "tests/test_web.py" = "files/tests/test_web.py.tmpl"
39
+
40
+ [notes]
41
+ next = [
42
+ "uv run ${name}-web",
43
+ "uv run ${name} debug:router",
44
+ "uv run pytest",
45
+ ]
@@ -0,0 +1,76 @@
1
+ """WebEntryPoint: what serves an application, and the command that starts it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Final, Literal, final
7
+
8
+ __all__ = ["DEFAULT_HOST", "DEFAULT_PORT", "WebEntryPoint"]
9
+
10
+ DEFAULT_HOST: Final = "127.0.0.1"
11
+ """Where an application found as an ASGI object is served when no host is given."""
12
+
13
+ DEFAULT_PORT: Final = 8000
14
+ """The port an application found as an ASGI object is served on when none is given."""
15
+
16
+
17
+ @final
18
+ @dataclass(frozen=True, slots=True)
19
+ class WebEntryPoint:
20
+ """One way to serve an application: a console script, or an ASGI object.
21
+
22
+ A **script** is the application's own web entry point, run as it is —
23
+ whatever it does before serving, loading its ``.env`` files among it, still
24
+ happens. It is told the host, the port and whether to reload through
25
+ ``WEB_HOST``, ``WEB_PORT`` and ``WEB_RELOAD``, the variables the ``webapp``
26
+ template's entry point reads; a script that reads none of them serves where
27
+ it always does.
28
+
29
+ An **ASGI object** — a module-level ``FastAPI()``, ``Starlette()`` or
30
+ ``Litestar()`` — is handed to ``uvicorn`` directly, which then has to be
31
+ installed in the project. Nothing runs before it is imported, so the
32
+ application's own environment loading does not either.
33
+
34
+ Attributes:
35
+ kind: ``"script"`` or ``"asgi"``.
36
+ target: The script's name, or the ASGI object as ``module:attribute``.
37
+ """
38
+
39
+ kind: Literal["script", "asgi"]
40
+ target: str
41
+
42
+ @property
43
+ def label(self) -> str:
44
+ """The entry point as ``--entrypoint`` takes it."""
45
+ return self.target
46
+
47
+ def command(
48
+ self, host: str | None, port: int | None, *, reload: bool
49
+ ) -> tuple[tuple[str, ...], dict[str, str]]:
50
+ """Return the command serving this entry point, and the variables set for it.
51
+
52
+ Args:
53
+ host: Where to listen, or ``None`` for the entry point's default.
54
+ port: The port to listen on, or ``None`` for the entry point's default.
55
+ reload: Whether to restart the server when a source file changes.
56
+ """
57
+ if self.kind == "script":
58
+ environment: dict[str, str] = {}
59
+ if host is not None:
60
+ environment["WEB_HOST"] = host
61
+ if port is not None:
62
+ environment["WEB_PORT"] = str(port)
63
+ if reload:
64
+ environment["WEB_RELOAD"] = "1"
65
+ return ("uv", "run", self.target), environment
66
+ argv = (
67
+ "uv",
68
+ "run",
69
+ "uvicorn",
70
+ self.target,
71
+ "--host",
72
+ host if host is not None else DEFAULT_HOST,
73
+ "--port",
74
+ str(port if port is not None else DEFAULT_PORT),
75
+ )
76
+ return ((*argv, "--reload") if reload else argv), {}
@@ -0,0 +1,194 @@
1
+ """WebEntryPointFinder: the project around a directory, and what in it serves the web."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, final
9
+
10
+ from .exception import ProjectNotFoundError, WebEntryPointNotFoundError
11
+ from .web_entry_point import WebEntryPoint
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+
16
+ __all__ = ["WebEntryPointFinder"]
17
+
18
+ _PYPROJECT = "pyproject.toml"
19
+ # A script importing one of these starts a server itself.
20
+ _SERVERS = frozenset({"uvicorn", "hypercorn", "granian"})
21
+ # A script named like this is the web entry point by convention.
22
+ _WEB_SUFFIXES = ("-web", "_web", "-server", "_server")
23
+ # The classes whose module-level instance is an ASGI application.
24
+ _ASGI_CLASSES = frozenset({"FastAPI", "Starlette", "Litestar"})
25
+ # Directories that hold no application code, at whatever depth they appear.
26
+ _SKIPPED = frozenset(
27
+ {"__pycache__", "node_modules", "build", "dist", "tests", "test", "migrations", "var"}
28
+ )
29
+
30
+
31
+ @final
32
+ class WebEntryPointFinder:
33
+ """Finds what serves a project, reading its files and running nothing.
34
+
35
+ A web console script comes first: it is the application's own way of
36
+ serving, environment loading included. A script counts when its module
37
+ imports a server — ``uvicorn``, ``hypercorn`` or ``granian`` — or its name
38
+ ends in ``-web`` or ``-server``. Only a project with no such script is
39
+ searched for a module-level ASGI object instead. Every module is read with
40
+ :mod:`ast`, never imported.
41
+ """
42
+
43
+ __slots__ = ()
44
+
45
+ @staticmethod
46
+ def project_dir(start: Path | None = None) -> Path:
47
+ """Return the nearest directory at or above ``start`` holding a ``pyproject.toml``.
48
+
49
+ Raises:
50
+ ProjectNotFoundError: When there is none.
51
+ """
52
+ origin = (start if start is not None else Path.cwd()).expanduser().resolve()
53
+ for directory in (origin, *origin.parents):
54
+ if (directory / _PYPROJECT).is_file():
55
+ return directory
56
+ raise ProjectNotFoundError(origin)
57
+
58
+ def find(self, project_dir: Path) -> tuple[WebEntryPoint, ...]:
59
+ """Return every web entry point of the project, the scripts first and alone if any."""
60
+ scripts = tuple(
61
+ WebEntryPoint("script", name)
62
+ for name, target in _scripts(project_dir).items()
63
+ if name.endswith(_WEB_SUFFIXES) or _imports_a_server(project_dir, target)
64
+ )
65
+ if scripts:
66
+ return scripts
67
+ return tuple(WebEntryPoint("asgi", target) for target in _asgi_objects(project_dir))
68
+
69
+ @staticmethod
70
+ def resolve(project_dir: Path, given: str) -> WebEntryPoint:
71
+ """Return the entry point ``--entrypoint`` names: a script, or ``module:attribute``.
72
+
73
+ Raises:
74
+ WebEntryPointNotFoundError: When ``given`` is neither one of the
75
+ project's scripts nor a ``module:attribute``.
76
+ """
77
+ module, separator, attribute = given.partition(":")
78
+ if separator:
79
+ if all(part.isidentifier() for part in module.split(".")) and attribute.isidentifier():
80
+ return WebEntryPoint("asgi", given)
81
+ raise WebEntryPointNotFoundError(project_dir, f"{given!r} is not a module:attribute")
82
+ if given in _scripts(project_dir):
83
+ return WebEntryPoint("script", given)
84
+ raise WebEntryPointNotFoundError(
85
+ project_dir,
86
+ f"no script {given!r} in [project.scripts]; give a script name or a module:attribute",
87
+ )
88
+
89
+
90
+ def _scripts(project_dir: Path) -> dict[str, str]:
91
+ """Return ``[project.scripts]``, each name mapped to its ``module:function``."""
92
+ try:
93
+ with (project_dir / _PYPROJECT).open("rb") as handle:
94
+ data: dict[str, object] = tomllib.load(handle)
95
+ except (OSError, tomllib.TOMLDecodeError):
96
+ # A manifest that cannot be read has no scripts to offer; the ASGI search still runs.
97
+ return {}
98
+ project = data.get("project")
99
+ scripts = project.get("scripts") if isinstance(project, dict) else None
100
+ if not isinstance(scripts, dict):
101
+ return {}
102
+ return {str(name): str(target) for name, target in scripts.items() if isinstance(target, str)}
103
+
104
+
105
+ def _roots(project_dir: Path) -> tuple[Path, ...]:
106
+ """Return where the project's modules live: ``src/`` when present, and the project itself."""
107
+ source = project_dir / "src"
108
+ return (source, project_dir) if source.is_dir() else (project_dir,)
109
+
110
+
111
+ def _module_file(project_dir: Path, module: str) -> Path | None:
112
+ """Return the file defining ``module``, looked for where the project keeps its code."""
113
+ parts = module.split(".")
114
+ for root in _roots(project_dir):
115
+ for candidate in (
116
+ root.joinpath(*parts).with_suffix(".py"),
117
+ root.joinpath(*parts, "__init__.py"),
118
+ ):
119
+ if candidate.is_file():
120
+ return candidate
121
+ return None
122
+
123
+
124
+ def _parse(file: Path) -> ast.Module | None:
125
+ """Parse ``file``, or return ``None`` for one that cannot be read as Python."""
126
+ try:
127
+ return ast.parse(file.read_text(encoding="utf-8"), str(file))
128
+ except (OSError, UnicodeDecodeError, SyntaxError, ValueError):
129
+ # Not this tool's to report: a broken module is simply not an entry point.
130
+ return None
131
+
132
+
133
+ def _imports_a_server(project_dir: Path, target: str) -> bool:
134
+ """Report whether the module a script runs imports a server."""
135
+ file = _module_file(project_dir, target.partition(":")[0].strip())
136
+ tree = _parse(file) if file is not None else None
137
+ if tree is None:
138
+ return False
139
+ for node in ast.walk(tree):
140
+ if isinstance(node, ast.Import):
141
+ names = [alias.name for alias in node.names]
142
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
143
+ names = [node.module]
144
+ else:
145
+ continue
146
+ if any(name.partition(".")[0] in _SERVERS for name in names):
147
+ return True
148
+ return False
149
+
150
+
151
+ def _asgi_objects(project_dir: Path) -> list[str]:
152
+ """Return every module-level ASGI application, as ``module:attribute``, sorted."""
153
+ found: set[str] = set()
154
+ for root in _roots(project_dir):
155
+ for file in _modules(root, project_dir):
156
+ parts = list(file.relative_to(root).with_suffix("").parts)
157
+ if parts[-1] == "__init__":
158
+ parts = parts[:-1]
159
+ if not parts or parts[-1] == "__main__" or not all(p.isidentifier() for p in parts):
160
+ continue
161
+ tree = _parse(file)
162
+ if tree is not None:
163
+ found.update(f"{'.'.join(parts)}:{name}" for name in _asgi_names(tree))
164
+ return sorted(found)
165
+
166
+
167
+ def _modules(root: Path, project_dir: Path) -> Iterator[Path]:
168
+ """Yield the Python files under ``root``, leaving out tooling, tests and other roots."""
169
+ for path in sorted(root.iterdir()):
170
+ if path.name.startswith(".") or path.name in _SKIPPED:
171
+ continue
172
+ if path.is_dir():
173
+ if path == project_dir / "src" or (path / "pyvenv.cfg").exists():
174
+ continue
175
+ yield from _modules(path, project_dir)
176
+ elif path.suffix == ".py":
177
+ yield path
178
+
179
+
180
+ def _asgi_names(tree: ast.Module) -> Iterator[str]:
181
+ """Yield the module-level names bound to a new ASGI application."""
182
+ for node in tree.body:
183
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
184
+ target, value = node.targets[0], node.value
185
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
186
+ target, value = node.target, node.value
187
+ else:
188
+ continue
189
+ if not isinstance(target, ast.Name) or not isinstance(value, ast.Call):
190
+ continue
191
+ called = value.func
192
+ name = called.id if isinstance(called, ast.Name) else getattr(called, "attr", None)
193
+ if name in _ASGI_CLASSES:
194
+ yield target.id