xtr-cli 4.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_cli/__init__.py +79 -0
- xtr_cli/__main__.py +28 -0
- xtr_cli/add_on.py +52 -0
- xtr_cli/add_on_catalog.py +167 -0
- xtr_cli/add_on_choice.py +40 -0
- xtr_cli/command/__init__.py +14 -0
- xtr_cli/command/init_command.py +69 -0
- xtr_cli/command/new_command.py +72 -0
- xtr_cli/command/serve_command.py +114 -0
- xtr_cli/command_support.py +189 -0
- xtr_cli/exception/__init__.py +34 -0
- xtr_cli/exception/ambiguous_web_entry_point_error.py +27 -0
- xtr_cli/exception/cli_error.py +14 -0
- xtr_cli/exception/directory_not_empty_error.py +35 -0
- xtr_cli/exception/invalid_project_name_error.py +33 -0
- xtr_cli/exception/invalid_template_error.py +28 -0
- xtr_cli/exception/project_not_found_error.py +28 -0
- xtr_cli/exception/step_failed_error.py +38 -0
- xtr_cli/exception/unknown_add_on_error.py +28 -0
- xtr_cli/exception/unknown_template_error.py +29 -0
- xtr_cli/exception/unsupported_python_error.py +28 -0
- xtr_cli/exception/web_entry_point_not_found_error.py +31 -0
- xtr_cli/process_runner_interface.py +29 -0
- xtr_cli/project_name.py +55 -0
- xtr_cli/project_questions.py +108 -0
- xtr_cli/project_settings.py +89 -0
- xtr_cli/py.typed +0 -0
- xtr_cli/scaffold_plan.py +41 -0
- xtr_cli/scaffolder.py +159 -0
- xtr_cli/subprocess_runner.py +47 -0
- xtr_cli/template.py +178 -0
- xtr_cli/template_catalog.py +62 -0
- xtr_cli/templates/__init__.py +10 -0
- xtr_cli/templates/cli/files/README.md.tmpl +44 -0
- xtr_cli/templates/cli/files/app/__init__.py.tmpl +5 -0
- xtr_cli/templates/cli/files/app/__main__.py.tmpl +23 -0
- xtr_cli/templates/cli/files/app/command/__init__.py.tmpl +5 -0
- xtr_cli/templates/cli/files/app/command/hello_command.py.tmpl +19 -0
- xtr_cli/templates/cli/files/app/kernel.py.tmpl +34 -0
- xtr_cli/templates/cli/files/app/py.typed.tmpl +0 -0
- xtr_cli/templates/cli/files/gitignore.tmpl +14 -0
- xtr_cli/templates/cli/files/pyproject.toml.tmpl +59 -0
- xtr_cli/templates/cli/files/python-version.tmpl +1 -0
- xtr_cli/templates/cli/files/tests/__init__.py.tmpl +0 -0
- xtr_cli/templates/cli/files/tests/test_kernel.py.tmpl +9 -0
- xtr_cli/templates/cli/template.toml +34 -0
- xtr_cli/templates/webapp/files/README.md.tmpl +49 -0
- xtr_cli/templates/webapp/files/app/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/__main__.py.tmpl +23 -0
- xtr_cli/templates/webapp/files/app/command/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/command/hello_command.py.tmpl +19 -0
- xtr_cli/templates/webapp/files/app/config/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/config/http_kernel.py.tmpl +19 -0
- xtr_cli/templates/webapp/files/app/kernel.py.tmpl +37 -0
- xtr_cli/templates/webapp/files/app/py.typed.tmpl +0 -0
- xtr_cli/templates/webapp/files/app/web/__init__.py.tmpl +5 -0
- xtr_cli/templates/webapp/files/app/web/__main__.py.tmpl +42 -0
- xtr_cli/templates/webapp/files/app/web/app.py.tmpl +23 -0
- xtr_cli/templates/webapp/files/app/web/routes.py.tmpl +15 -0
- xtr_cli/templates/webapp/files/gitignore.tmpl +14 -0
- xtr_cli/templates/webapp/files/pyproject.toml.tmpl +60 -0
- xtr_cli/templates/webapp/files/python-version.tmpl +1 -0
- xtr_cli/templates/webapp/files/tests/__init__.py.tmpl +0 -0
- xtr_cli/templates/webapp/files/tests/test_kernel.py.tmpl +9 -0
- xtr_cli/templates/webapp/files/tests/test_web.py.tmpl +28 -0
- xtr_cli/templates/webapp/template.toml +45 -0
- xtr_cli/web_entry_point.py +76 -0
- xtr_cli/web_entry_point_finder.py +194 -0
- xtr_cli-4.0.2.dist-info/METADATA +307 -0
- xtr_cli-4.0.2.dist-info/RECORD +73 -0
- xtr_cli-4.0.2.dist-info/WHEEL +4 -0
- xtr_cli-4.0.2.dist-info/entry_points.txt +4 -0
- xtr_cli-4.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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,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
|