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 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))