pyvel-fastapi 0.1.0__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.
- pyvel/__init__.py +8 -0
- pyvel/app.py +46 -0
- pyvel/config.py +172 -0
- pyvel/errors.py +17 -0
- pyvel/events.py +41 -0
- pyvel/generate.py +42 -0
- pyvel/init_cmd.py +250 -0
- pyvel/list_cmd.py +55 -0
- pyvel/make.py +259 -0
- pyvel/paths.py +40 -0
- pyvel/registry.py +78 -0
- pyvel/templates.py +379 -0
- pyvel_fastapi-0.1.0.dist-info/METADATA +207 -0
- pyvel_fastapi-0.1.0.dist-info/RECORD +16 -0
- pyvel_fastapi-0.1.0.dist-info/WHEEL +4 -0
- pyvel_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
pyvel/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""pyvel — FastAPI scaffolding CLI and shared runtime bases."""
|
|
2
|
+
|
|
3
|
+
from pyvel.errors import AppError
|
|
4
|
+
from pyvel.events import EventDispatcher, dispatcher
|
|
5
|
+
from pyvel.registry import register
|
|
6
|
+
|
|
7
|
+
__all__ = ["AppError", "EventDispatcher", "dispatcher", "register"]
|
|
8
|
+
__version__ = "0.1.0"
|
pyvel/app.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""pyvel CLI entrypoints (`pyvel`, `artisan`, and `dev`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from pyvel import init_cmd, list_cmd, make
|
|
11
|
+
from pyvel.config import clear_settings_cache, load_settings
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="pyvel",
|
|
15
|
+
help="Laravel-shaped scaffolding CLI for Python API backends on FastAPI.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
add_completion=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
init_cmd.register(app)
|
|
21
|
+
make.register(app)
|
|
22
|
+
list_cmd.register(app)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clear_terminal() -> None:
|
|
26
|
+
"""Clear the screen and scrollback buffer."""
|
|
27
|
+
sys.stdout.write("\033[2J\033[3J\033[H")
|
|
28
|
+
sys.stdout.flush()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dev() -> None:
|
|
32
|
+
"""Run the API with settings from optional `pyvel.toml`."""
|
|
33
|
+
clear_settings_cache()
|
|
34
|
+
settings = load_settings()
|
|
35
|
+
dev_cfg = settings.resolved_dev()
|
|
36
|
+
_clear_terminal()
|
|
37
|
+
uvicorn.run(
|
|
38
|
+
dev_cfg.module,
|
|
39
|
+
host=dev_cfg.host,
|
|
40
|
+
port=dev_cfg.port,
|
|
41
|
+
reload=dev_cfg.reload,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
app()
|
pyvel/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Load optional project-root `pyvel.toml` with built-in defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_PATHS: dict[str, str] = {
|
|
13
|
+
"errors": "http/exception/errors",
|
|
14
|
+
"handlers": "http/exception/handlers",
|
|
15
|
+
"requests": "http/requests",
|
|
16
|
+
"responses": "http/responses",
|
|
17
|
+
"middleware": "http/middleware",
|
|
18
|
+
"routers": "http/routers",
|
|
19
|
+
"dependencies": "http/dependencies",
|
|
20
|
+
"services": "services",
|
|
21
|
+
"schemas": "schemas",
|
|
22
|
+
"enums": "domain/enums",
|
|
23
|
+
"config": "config",
|
|
24
|
+
"events": "events",
|
|
25
|
+
"listeners": "listeners",
|
|
26
|
+
"jobs": "jobs",
|
|
27
|
+
"tests": "tests",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DEFAULT_ERROR_BASE = "pyvel.errors:AppError"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class DevSettings:
|
|
35
|
+
"""Uvicorn settings for `pyvel` / `dev`."""
|
|
36
|
+
|
|
37
|
+
module: str
|
|
38
|
+
host: str = "0.0.0.0"
|
|
39
|
+
port: int = 8000
|
|
40
|
+
reload: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PyvelSettings:
|
|
45
|
+
"""Resolved project settings for generators and runtime registration."""
|
|
46
|
+
|
|
47
|
+
root: Path
|
|
48
|
+
app: str
|
|
49
|
+
src: str = "src"
|
|
50
|
+
paths: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_PATHS))
|
|
51
|
+
error_base: str = DEFAULT_ERROR_BASE
|
|
52
|
+
dev: DevSettings | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def app_dir(self) -> Path:
|
|
56
|
+
"""Filesystem path to the application package."""
|
|
57
|
+
return self.root / self.src / self.app
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def tests_dir(self) -> Path:
|
|
61
|
+
"""Filesystem path to the tests directory."""
|
|
62
|
+
return self.root / self.paths.get("tests", "tests")
|
|
63
|
+
|
|
64
|
+
def path(self, key: str) -> Path:
|
|
65
|
+
"""Return an absolute path under the app package for a `[paths]` key."""
|
|
66
|
+
relative = self.paths.get(key, DEFAULT_PATHS[key])
|
|
67
|
+
return self.app_dir / relative
|
|
68
|
+
|
|
69
|
+
def package(self, key: str) -> str:
|
|
70
|
+
"""Return a dotted package name for a `[paths]` key under the app."""
|
|
71
|
+
relative = self.paths.get(key, DEFAULT_PATHS[key])
|
|
72
|
+
return f"{self.app}." + relative.replace("/", ".").replace("\\", ".")
|
|
73
|
+
|
|
74
|
+
def error_base_parts(self) -> tuple[str, str]:
|
|
75
|
+
"""Split `error_base` into `(module, attr)`."""
|
|
76
|
+
module, _, attr = self.error_base.partition(":")
|
|
77
|
+
if not module or not attr:
|
|
78
|
+
raise ValueError(f"Invalid [bases].error value: {self.error_base!r}")
|
|
79
|
+
return module, attr
|
|
80
|
+
|
|
81
|
+
def resolved_dev(self) -> DevSettings:
|
|
82
|
+
"""Return dev settings, defaulting module to `{app}.main:app`."""
|
|
83
|
+
if self.dev is not None:
|
|
84
|
+
return self.dev
|
|
85
|
+
return DevSettings(module=f"{self.app}.main:app")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
89
|
+
"""Walk upward until `pyvel.toml` or `pyproject.toml` is found."""
|
|
90
|
+
current = (start or Path.cwd()).resolve()
|
|
91
|
+
for path in [current, *current.parents]:
|
|
92
|
+
if (path / "pyvel.toml").is_file() or (path / "pyproject.toml").is_file():
|
|
93
|
+
return path
|
|
94
|
+
raise FileNotFoundError("Could not find project root (pyvel.toml or pyproject.toml).")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
98
|
+
if not path.is_file():
|
|
99
|
+
return {}
|
|
100
|
+
with path.open("rb") as handle:
|
|
101
|
+
return tomllib.load(handle)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _app_from_pyproject(root: Path) -> str | None:
|
|
105
|
+
data = _read_toml(root / "pyproject.toml")
|
|
106
|
+
tool = data.get("tool", {})
|
|
107
|
+
for key in ("pyvel", "artisan"):
|
|
108
|
+
app = tool.get(key, {}).get("app")
|
|
109
|
+
if isinstance(app, str) and app:
|
|
110
|
+
return app
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@lru_cache(maxsize=8)
|
|
115
|
+
def load_settings(start: str | None = None) -> PyvelSettings:
|
|
116
|
+
"""Load settings from `pyvel.toml` / pyproject with defaults.
|
|
117
|
+
|
|
118
|
+
Pass *start* as a string path for cacheability; use ``None`` for cwd.
|
|
119
|
+
"""
|
|
120
|
+
root = find_project_root(Path(start) if start else None)
|
|
121
|
+
file_data = _read_toml(root / "pyvel.toml")
|
|
122
|
+
|
|
123
|
+
app = file_data.get("app") or _app_from_pyproject(root)
|
|
124
|
+
if not app:
|
|
125
|
+
# Last resort: first package under src/ that isn't a known tool package
|
|
126
|
+
src_dir = root / str(file_data.get("src", "src"))
|
|
127
|
+
candidates = [
|
|
128
|
+
p.name
|
|
129
|
+
for p in src_dir.iterdir()
|
|
130
|
+
if p.is_dir() and (p / "__init__.py").exists() and p.name not in {"pyvel", "cli"}
|
|
131
|
+
] if src_dir.is_dir() else []
|
|
132
|
+
if not candidates:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
"Could not determine app package name. "
|
|
135
|
+
"Set `app` in pyvel.toml or run `pyvel init <name>`."
|
|
136
|
+
)
|
|
137
|
+
app = sorted(candidates)[0]
|
|
138
|
+
|
|
139
|
+
src = str(file_data.get("src", "src"))
|
|
140
|
+
paths = dict(DEFAULT_PATHS)
|
|
141
|
+
paths.update({k: str(v) for k, v in (file_data.get("paths") or {}).items()})
|
|
142
|
+
|
|
143
|
+
bases = file_data.get("bases") or {}
|
|
144
|
+
error_base = str(bases.get("error", DEFAULT_ERROR_BASE))
|
|
145
|
+
|
|
146
|
+
dev_data = file_data.get("dev") or {}
|
|
147
|
+
module = dev_data.get("module") or f"{app}.main:app"
|
|
148
|
+
dev = DevSettings(
|
|
149
|
+
module=str(module),
|
|
150
|
+
host=str(dev_data.get("host", "0.0.0.0")),
|
|
151
|
+
port=int(dev_data.get("port", 8000)),
|
|
152
|
+
reload=bool(dev_data.get("reload", True)),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return PyvelSettings(
|
|
156
|
+
root=root,
|
|
157
|
+
app=str(app),
|
|
158
|
+
src=src,
|
|
159
|
+
paths=paths,
|
|
160
|
+
error_base=error_base,
|
|
161
|
+
dev=dev,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def clear_settings_cache() -> None:
|
|
166
|
+
"""Clear the cached settings (useful after init / tests)."""
|
|
167
|
+
load_settings.cache_clear()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def with_overrides(settings: PyvelSettings, **kwargs: Any) -> PyvelSettings:
|
|
171
|
+
"""Return a copy of *settings* with selected fields replaced."""
|
|
172
|
+
return replace(settings, **kwargs)
|
pyvel/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared application error base class."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppError(Exception):
|
|
5
|
+
"""Base class for domain/HTTP-mapped application errors."""
|
|
6
|
+
|
|
7
|
+
status_code: int = 400
|
|
8
|
+
code: str = "app_error"
|
|
9
|
+
message: str = "Application error"
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str | None = None, *, code: str | None = None) -> None:
|
|
12
|
+
"""Optionally override the default message and error code."""
|
|
13
|
+
if message is not None:
|
|
14
|
+
self.message = message
|
|
15
|
+
if code is not None:
|
|
16
|
+
self.code = code
|
|
17
|
+
super().__init__(self.message)
|
pyvel/events.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""In-process event dispatcher for generated listeners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
Listener = Callable[[Any], Any | Awaitable[Any]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventDispatcher:
|
|
15
|
+
"""Subscribe handlers to event types and dispatch payloads in-process."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._listeners: dict[type, list[Listener]] = defaultdict(list)
|
|
19
|
+
|
|
20
|
+
def subscribe(self, event_type: type, listener: Listener) -> None:
|
|
21
|
+
"""Register *listener* for *event_type*."""
|
|
22
|
+
self._listeners[event_type].append(listener)
|
|
23
|
+
|
|
24
|
+
async def dispatch(self, event: Any) -> None:
|
|
25
|
+
"""Invoke all listeners registered for the event's type."""
|
|
26
|
+
for listener in self._listeners.get(type(event), []):
|
|
27
|
+
result = listener(event)
|
|
28
|
+
if inspect.isawaitable(result):
|
|
29
|
+
await result
|
|
30
|
+
|
|
31
|
+
def dispatch_sync(self, event: Any) -> None:
|
|
32
|
+
"""Dispatch from sync code by running the async path when needed."""
|
|
33
|
+
try:
|
|
34
|
+
loop = asyncio.get_running_loop()
|
|
35
|
+
except RuntimeError:
|
|
36
|
+
asyncio.run(self.dispatch(event))
|
|
37
|
+
return
|
|
38
|
+
loop.create_task(self.dispatch(event))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
dispatcher = EventDispatcher()
|
pyvel/generate.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Naming helpers and file writers for generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pyvel.paths import ensure_package
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_snake(name: str) -> str:
|
|
14
|
+
"""Convert PascalCase / camelCase / kebab-case to snake_case."""
|
|
15
|
+
cleaned = name.replace("-", "_").replace(" ", "_")
|
|
16
|
+
cleaned = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", cleaned)
|
|
17
|
+
cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", cleaned)
|
|
18
|
+
return cleaned.lower().strip("_")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_pascal(name: str) -> str:
|
|
22
|
+
"""Convert a name to PascalCase."""
|
|
23
|
+
parts = re.split(r"[-_\s]+", name)
|
|
24
|
+
if len(parts) == 1 and name[:1].isupper() and "_" not in name and "-" not in name:
|
|
25
|
+
return name[0].upper() + name[1:] if name else name
|
|
26
|
+
return "".join(part.capitalize() for part in parts if part)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_suffix(pascal: str, suffix: str) -> str:
|
|
30
|
+
"""Append *suffix* when the PascalCase name does not already end with it."""
|
|
31
|
+
if pascal.endswith(suffix):
|
|
32
|
+
return pascal
|
|
33
|
+
return f"{pascal}{suffix}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_file(path: Path, content: str, *, force: bool = False) -> Path:
|
|
37
|
+
"""Write *content* to *path*, creating parent packages as needed."""
|
|
38
|
+
ensure_package(path.parent)
|
|
39
|
+
if path.exists() and not force:
|
|
40
|
+
raise typer.BadParameter(f"File already exists: {path} (use --force to overwrite)")
|
|
41
|
+
path.write_text(content.lstrip("\n"), encoding="utf-8")
|
|
42
|
+
return path
|
pyvel/init_cmd.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""`pyvel init` — scaffold a Laravel-like FastAPI backend project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from pyvel.config import DEFAULT_PATHS, clear_settings_cache
|
|
13
|
+
from pyvel.generate import to_snake
|
|
14
|
+
from pyvel.paths import ensure_package
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SCAFFOLD_MARKERS = (
|
|
18
|
+
"pyproject.toml",
|
|
19
|
+
"pyvel.toml",
|
|
20
|
+
"src",
|
|
21
|
+
"tests",
|
|
22
|
+
"README.md",
|
|
23
|
+
".gitignore",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(app: typer.Typer) -> None:
|
|
28
|
+
"""Attach the init command to *app*."""
|
|
29
|
+
|
|
30
|
+
@app.command("init")
|
|
31
|
+
def init(
|
|
32
|
+
name: str | None = typer.Argument(
|
|
33
|
+
None,
|
|
34
|
+
help="Project/app name. Omit to scaffold into the current directory.",
|
|
35
|
+
),
|
|
36
|
+
force: bool = typer.Option(False, "--force", help="Overwrite conflicting scaffold files"),
|
|
37
|
+
pyvel_path: Path | None = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--pyvel-path",
|
|
40
|
+
help="Local path to pyvel package (adds a uv path source; useful before PyPI publish)",
|
|
41
|
+
exists=True,
|
|
42
|
+
file_okay=False,
|
|
43
|
+
resolve_path=True,
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Scaffold a Laravel-like FastAPI backend (dual-mode: cwd or new directory)."""
|
|
47
|
+
cwd = Path.cwd().resolve()
|
|
48
|
+
if name:
|
|
49
|
+
app_name = _normalize_app_name(name)
|
|
50
|
+
target = cwd / app_name
|
|
51
|
+
if target.exists() and any(target.iterdir()) and not force:
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
f"Directory {target} is not empty (use --force to scaffold anyway)"
|
|
54
|
+
)
|
|
55
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
else:
|
|
57
|
+
app_name = _normalize_app_name(cwd.name)
|
|
58
|
+
target = cwd
|
|
59
|
+
if _cwd_blocked(target) and not force:
|
|
60
|
+
raise typer.BadParameter(
|
|
61
|
+
"Current directory already looks initialized "
|
|
62
|
+
"(pyproject.toml / pyvel.toml / src). Use --force to continue."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_scaffold(target, app_name, force=force, pyvel_path=pyvel_path)
|
|
66
|
+
clear_settings_cache()
|
|
67
|
+
typer.secho(f"Initialized FastAPI app '{app_name}' in {target}", fg=typer.colors.GREEN)
|
|
68
|
+
_maybe_uv_sync(target)
|
|
69
|
+
typer.echo(f"Next: cd {target if name else '.'} && uv run dev")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize_app_name(raw: str) -> str:
|
|
73
|
+
"""Normalize a user-given name into a valid Python package identifier."""
|
|
74
|
+
snake = to_snake(raw.replace(".", "_"))
|
|
75
|
+
snake = re.sub(r"[^a-z0-9_]", "_", snake)
|
|
76
|
+
snake = re.sub(r"_+", "_", snake).strip("_")
|
|
77
|
+
if not snake or snake[0].isdigit():
|
|
78
|
+
raise typer.BadParameter(f"Invalid app name: {raw!r}")
|
|
79
|
+
return snake
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cwd_blocked(target: Path) -> bool:
|
|
83
|
+
"""Return True when cwd already has scaffold markers."""
|
|
84
|
+
return any((target / marker).exists() for marker in _SCAFFOLD_MARKERS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _scaffold(
|
|
88
|
+
target: Path,
|
|
89
|
+
app_name: str,
|
|
90
|
+
*,
|
|
91
|
+
force: bool,
|
|
92
|
+
pyvel_path: Path | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Write project files for *app_name* under *target*."""
|
|
95
|
+
_write(
|
|
96
|
+
target / "pyproject.toml",
|
|
97
|
+
_pyproject(app_name, pyvel_path=pyvel_path, target=target),
|
|
98
|
+
force=force,
|
|
99
|
+
)
|
|
100
|
+
_write(target / "pyvel.toml", _pyvel_toml(app_name), force=force)
|
|
101
|
+
_write(target / "README.md", _readme(app_name), force=force)
|
|
102
|
+
_write(target / ".gitignore", _gitignore(), force=force)
|
|
103
|
+
|
|
104
|
+
app_dir = target / "src" / app_name
|
|
105
|
+
ensure_package(app_dir)
|
|
106
|
+
_write(app_dir / "__init__.py", f'"""{app_name} application package."""\n\n__version__ = "0.1.0"\n', force=True)
|
|
107
|
+
_write(app_dir / "main.py", _main_py(app_name), force=force)
|
|
108
|
+
|
|
109
|
+
for key, relative in DEFAULT_PATHS.items():
|
|
110
|
+
if key == "tests":
|
|
111
|
+
continue
|
|
112
|
+
ensure_package(app_dir / relative)
|
|
113
|
+
|
|
114
|
+
(target / "tests").mkdir(exist_ok=True)
|
|
115
|
+
_write(target / "tests" / ".gitkeep", "", force=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write(path: Path, content: str, *, force: bool) -> None:
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
if path.exists() and not force and path.stat().st_size > 0:
|
|
121
|
+
typer.secho(f"Skip existing {path}", fg=typer.colors.YELLOW)
|
|
122
|
+
return
|
|
123
|
+
path.write_text(content, encoding="utf-8")
|
|
124
|
+
typer.echo(f"Created {path}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _pyproject(app: str, *, pyvel_path: Path | None = None, target: Path | None = None) -> str:
|
|
128
|
+
sources = ""
|
|
129
|
+
if pyvel_path is not None and target is not None:
|
|
130
|
+
rel = Path(os.path.relpath(pyvel_path, target)).as_posix()
|
|
131
|
+
sources = f'''
|
|
132
|
+
|
|
133
|
+
[tool.uv.sources]
|
|
134
|
+
pyvel-fastapi = {{ path = "{rel}", editable = true }}
|
|
135
|
+
'''
|
|
136
|
+
return f'''\
|
|
137
|
+
[project]
|
|
138
|
+
name = "{app}"
|
|
139
|
+
version = "0.1.0"
|
|
140
|
+
description = "{app} FastAPI backend"
|
|
141
|
+
requires-python = ">=3.11"
|
|
142
|
+
dependencies = [
|
|
143
|
+
"pyvel-fastapi",
|
|
144
|
+
"fastapi[standard]>=0.115.0",
|
|
145
|
+
"pydantic>=2.0",
|
|
146
|
+
"pydantic-settings>=2.0",
|
|
147
|
+
"uvicorn>=0.30.0",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
[project.scripts]
|
|
151
|
+
dev = "pyvel.app:dev"
|
|
152
|
+
pyvel = "pyvel.app:app"
|
|
153
|
+
artisan = "pyvel.app:app"
|
|
154
|
+
|
|
155
|
+
[build-system]
|
|
156
|
+
requires = ["uv_build>=0.12.18,<0.13.0"]
|
|
157
|
+
build-backend = "uv_build"
|
|
158
|
+
|
|
159
|
+
[tool.uv.build-backend]
|
|
160
|
+
module-name = "{app}"
|
|
161
|
+
|
|
162
|
+
[tool.pyvel]
|
|
163
|
+
app = "{app}"
|
|
164
|
+
{sources}'''
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _pyvel_toml(app: str) -> str:
|
|
168
|
+
return f'''\
|
|
169
|
+
# pyvel project config (all keys optional — omit sections to use defaults)
|
|
170
|
+
|
|
171
|
+
app = "{app}"
|
|
172
|
+
src = "src"
|
|
173
|
+
|
|
174
|
+
[dev]
|
|
175
|
+
module = "{app}.main:app"
|
|
176
|
+
host = "0.0.0.0"
|
|
177
|
+
port = 8000
|
|
178
|
+
reload = true
|
|
179
|
+
|
|
180
|
+
# [paths]
|
|
181
|
+
# errors = "http/exception/errors"
|
|
182
|
+
# handlers = "http/exception/handlers"
|
|
183
|
+
# ...
|
|
184
|
+
|
|
185
|
+
# [bases]
|
|
186
|
+
# error = "pyvel.errors:AppError"
|
|
187
|
+
'''
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _main_py(app: str) -> str:
|
|
191
|
+
title = app.replace("_", " ").title()
|
|
192
|
+
return f'''\
|
|
193
|
+
"""{title} FastAPI application."""
|
|
194
|
+
|
|
195
|
+
from fastapi import FastAPI
|
|
196
|
+
|
|
197
|
+
from pyvel import register
|
|
198
|
+
|
|
199
|
+
app = FastAPI(title="{title}")
|
|
200
|
+
register(app)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/")
|
|
204
|
+
def read_root() -> dict[str, str]:
|
|
205
|
+
"""Health/root endpoint."""
|
|
206
|
+
return {{"message": "Hello from {app}"}}
|
|
207
|
+
'''
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _readme(app: str) -> str:
|
|
211
|
+
return f"""\
|
|
212
|
+
# {app}
|
|
213
|
+
|
|
214
|
+
FastAPI backend scaffolded with [pyvel](https://pypi.org/project/pyvel/).
|
|
215
|
+
|
|
216
|
+
## Commands
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
uv sync
|
|
220
|
+
uv run dev
|
|
221
|
+
uv run pyvel --help
|
|
222
|
+
uv run pyvel make:resource User
|
|
223
|
+
uv run pyvel list:routes
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Configure the app in `pyvel.toml`.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _gitignore() -> str:
|
|
231
|
+
return """\
|
|
232
|
+
.venv/
|
|
233
|
+
__pycache__/
|
|
234
|
+
*.py[cod]
|
|
235
|
+
.pytest_cache/
|
|
236
|
+
.mypy_cache/
|
|
237
|
+
.ruff_cache/
|
|
238
|
+
dist/
|
|
239
|
+
*.egg-info/
|
|
240
|
+
.env
|
|
241
|
+
.DS_Store
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _maybe_uv_sync(target: Path) -> None:
|
|
246
|
+
"""Run `uv sync` in *target* when uv is available."""
|
|
247
|
+
try:
|
|
248
|
+
subprocess.run(["uv", "sync"], cwd=target, check=False)
|
|
249
|
+
except FileNotFoundError:
|
|
250
|
+
typer.secho("uv not found on PATH — skip `uv sync`. Install uv then run it manually.", fg=typer.colors.YELLOW)
|
pyvel/list_cmd.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Inspection commands such as `list:routes`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from fastapi.routing import APIRoute, _IncludedRouter
|
|
10
|
+
from starlette.routing import BaseRoute
|
|
11
|
+
|
|
12
|
+
from pyvel.paths import get_app_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(app: typer.Typer) -> None:
|
|
16
|
+
"""Attach inspection commands to *app*."""
|
|
17
|
+
|
|
18
|
+
@app.command("list:routes")
|
|
19
|
+
def list_routes() -> None:
|
|
20
|
+
"""Print registered FastAPI routes for the configured app."""
|
|
21
|
+
app_name = get_app_name()
|
|
22
|
+
module = importlib.import_module(f"{app_name}.main")
|
|
23
|
+
fastapi_app = getattr(module, "app", None)
|
|
24
|
+
if fastapi_app is None:
|
|
25
|
+
raise typer.BadParameter(f"No FastAPI `app` found on {app_name}.main")
|
|
26
|
+
|
|
27
|
+
rows = list(_collect_routes(fastapi_app.routes))
|
|
28
|
+
if not rows:
|
|
29
|
+
typer.echo("No routes registered.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
method_width = max(len(method) for method, _, _ in rows)
|
|
33
|
+
path_width = max(len(path) for _, path, _ in rows)
|
|
34
|
+
for method, path, name in sorted(rows, key=lambda item: (item[1], item[0])):
|
|
35
|
+
typer.echo(f"{method:<{method_width}} {path:<{path_width}} {name}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _collect_routes(routes: list[BaseRoute]) -> Iterator[tuple[str, str, str]]:
|
|
39
|
+
"""Yield `(method, path, name)` for API routes, including nested routers."""
|
|
40
|
+
for route in routes:
|
|
41
|
+
if isinstance(route, APIRoute):
|
|
42
|
+
methods = sorted(route.methods or [])
|
|
43
|
+
for method in methods:
|
|
44
|
+
if method == "HEAD":
|
|
45
|
+
continue
|
|
46
|
+
yield method, route.path, route.name or ""
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
if isinstance(route, _IncludedRouter):
|
|
50
|
+
yield from _collect_routes(route.original_router.routes)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
nested = getattr(route, "routes", None)
|
|
54
|
+
if nested:
|
|
55
|
+
yield from _collect_routes(list(nested))
|