Skip to content

Commit e428c84

Browse files
committed
feat: extract bp CLI as a uv workspace member
1 parent f4d0961 commit e428c84

24 files changed

Lines changed: 4373 additions & 0 deletions

backend/pyproject.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ ignore_missing_imports = true
108108
module = "sqlalchemy.ext.asyncio"
109109
ignore_errors = true
110110

111+
[[tool.mypy.overrides]]
112+
module = "taskiq"
113+
ignore_missing_imports = true
114+
115+
[[tool.mypy.overrides]]
116+
module = "taskiq.*"
117+
ignore_missing_imports = true
118+
119+
[[tool.mypy.overrides]]
120+
module = "taskiq_redis"
121+
ignore_missing_imports = true
122+
123+
[[tool.mypy.overrides]]
124+
module = "taskiq_aio_pika"
125+
ignore_missing_imports = true
126+
111127
[tool.ruff]
112128
line-length = 128
113129

backend/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# bp — FastAPI-boilerplate CLI
2+
3+
`bp` is the developer/operator command-line tool for projects built on the
4+
FastAPI boilerplate. It generates deployment artifacts, helps prepare the
5+
runtime environment, and serves as the host for plugin commands and feature
6+
generators.
7+
8+
## Install
9+
10+
This package is part of the workspace. From the repo root:
11+
12+
```bash
13+
uv sync # syncs the workspace; bp is available via `uv run bp`
14+
uv run bp --help
15+
```
16+
17+
To install `bp` machine-wide so it works outside this repo:
18+
19+
```bash
20+
uv tool install --editable ./cli
21+
bp --help
22+
```
23+
24+
## What's here
25+
26+
```
27+
cli/src/cli/
28+
├── app.py root Typer app + plugin discovery
29+
├── plugins.py entry-point loaders for bp.commands and bp.features
30+
├── commands/ in-tree command sub-apps
31+
│ ├── deploy.py bp deploy generate <mode>
32+
│ └── env.py bp env gen-secret / bp env validate
33+
├── features/ feature framework (manifest, plan, installer)
34+
│ └── _builtins/ in-tree features
35+
│ └── deploy/ compose/Dockerfile templates for local/prod/nginx
36+
└── lib/ shared helpers (project discovery, prompts, render)
37+
```
38+
39+
## Plugin extension points
40+
41+
Two kinds of plugins, kept deliberately separate:
42+
43+
- `bp.commands` entry-point group — third-party Typer sub-apps mounted under
44+
`bp <name>` (e.g. `bp aws deploy`).
45+
- `bp.features` entry-point group — code generators with a manifest that
46+
`bp feature` can list, install, and remove.
47+
48+
See `cli/src/cli/plugins.py` for the discovery contracts.

cli/pyproject.toml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[build-system]
2+
requires = ["setuptools", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "fastapi-boilerplate-cli"
7+
version = "0.1.0"
8+
description = "bp — developer/operator CLI for the FastAPI boilerplate. Hosts in-tree commands and discovers third-party plugins."
9+
authors = [{ name = "Benav Labs", email = "contact@benav.io" }]
10+
license = { text = "MIT" }
11+
readme = "README.md"
12+
requires-python = ">=3.11"
13+
dependencies = [
14+
"typer>=0.12",
15+
"jinja2>=3.1",
16+
"fastapi-boilerplate",
17+
]
18+
19+
[project.scripts]
20+
bp = "cli.app:app"
21+
22+
[tool.uv.sources]
23+
fastapi-boilerplate = { workspace = true }
24+
25+
[tool.setuptools]
26+
include-package-data = true
27+
28+
[tool.setuptools.packages.find]
29+
where = ["src"]
30+
include = ["*"]
31+
32+
[tool.setuptools.package-data]
33+
"*" = ["*.j2", "*.toml", "*.conf"]
34+
35+
[tool.ruff]
36+
line-length = 128
37+
38+
[tool.ruff.lint]
39+
select = ["E", "F", "I", "UP"]
40+
extend-select = ["UP006", "UP007", "UP035", "UP039"]
41+
42+
[tool.ruff.lint.isort]
43+
known-first-party = ["cli"]
44+
45+
[tool.mypy]
46+
python_version = "3.11"
47+
warn_return_any = true
48+
warn_unused_configs = true
49+
warn_unused_ignores = true
50+
namespace_packages = true
51+
explicit_package_bases = true
52+
53+
[[tool.mypy.overrides]]
54+
module = "infrastructure.*"
55+
ignore_missing_imports = true

cli/src/cli/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""bp — the FastAPI-boilerplate command-line tool.
2+
3+
The CLI is a Typer application with two extension points:
4+
5+
- `bp.commands` entry-point group: third-party packages can register
6+
top-level Typer sub-apps that mount under `bp <name>`.
7+
- `bp.features` entry-point group: third-party packages can register
8+
``Feature`` instances that ``bp feature`` can list, install, and remove.
9+
10+
In-tree commands and features live alongside this package and follow
11+
the same contracts as plugins.
12+
"""

cli/src/cli/app.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""bp — root Typer application and entry point.
2+
3+
Mounts in-tree command sub-apps and discovers third-party plugins.
4+
The shipped console script (``[project.scripts] bp``) targets
5+
``app`` directly.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import typer
11+
12+
from . import plugins as _plugins
13+
from .commands import deploy as _deploy_cmd
14+
from .commands import env as _env_cmd
15+
16+
app = typer.Typer(
17+
name="bp",
18+
help="FastAPI-boilerplate command-line tool.",
19+
no_args_is_help=True,
20+
pretty_exceptions_show_locals=False,
21+
)
22+
23+
# In-tree commands. Mounted before plugin discovery so a plugin can't
24+
# silently shadow a built-in by registering the same name.
25+
app.add_typer(_deploy_cmd.app, name="deploy", help="Generate deployment artifacts (Dockerfile, compose, nginx config).")
26+
app.add_typer(_env_cmd.app, name="env", help="Inspect and prepare the runtime environment.")
27+
28+
29+
def _mount_command_plugins() -> None:
30+
"""Mount external Typer sub-apps registered under ``bp.commands``."""
31+
builtin_names = {"deploy", "env", "feature"}
32+
for name, sub_app in _plugins.discover_command_plugins().items():
33+
if name in builtin_names:
34+
typer.secho(
35+
f"warning: plugin command '{name}' shadows a built-in; ignoring.",
36+
fg=typer.colors.YELLOW,
37+
err=True,
38+
)
39+
continue
40+
app.add_typer(sub_app, name=name)
41+
42+
43+
_mount_command_plugins()
44+
45+
46+
@app.callback()
47+
def _root() -> None:
48+
"""bp — FastAPI-boilerplate command-line tool."""
49+
# Typer uses this docstring as the root help text. The body is
50+
# intentionally empty: the callback exists so options like
51+
# ``--install-completion`` work without arguments.
52+
53+
54+
if __name__ == "__main__": # pragma: no cover
55+
app()

cli/src/cli/commands/__init__.py

Whitespace-only changes.

cli/src/cli/commands/deploy.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""``bp deploy`` — generate deployment artifacts.
2+
3+
Today this is just a wrapper around the in-tree ``deploy`` feature.
4+
Other deploy-adjacent commands (``bp deploy nginx-tls``, ``bp deploy
5+
github-actions``) can mount here as siblings.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from enum import StrEnum
11+
from pathlib import Path
12+
13+
import typer
14+
15+
from ..features.installer import FeatureInstaller
16+
from ..features.registry import get_feature
17+
from ..lib.project import discover_project
18+
from ..lib.prompts import error, info
19+
20+
app = typer.Typer(no_args_is_help=True, help="Generate deployment artifacts.")
21+
22+
23+
class DeployMode(StrEnum):
24+
local = "local"
25+
prod = "prod"
26+
nginx = "nginx"
27+
28+
29+
@app.command("generate")
30+
def generate(
31+
mode: DeployMode = typer.Argument(
32+
...,
33+
help="Deployment mode to generate. Pick `local` for hot-reload dev, `prod` for "
34+
"single-host production, `nginx` for production behind a reverse proxy.",
35+
),
36+
output_dir: Path = typer.Option(
37+
None,
38+
"--output-dir",
39+
"-o",
40+
help="Where to write the compose file. Defaults to the repo root.",
41+
),
42+
api_port: int = typer.Option(8000, "--api-port", help="Host port to publish the API on."),
43+
workers: int = typer.Option(4, "--workers", help="Number of API workers (prod / nginx only)."),
44+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files without asking."),
45+
yes: bool = typer.Option(False, "--yes", "-y", help="Assume yes for all prompts."),
46+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be written, don't touch disk."),
47+
) -> None:
48+
"""Generate ``docker-compose.yml`` (and ``nginx/default.conf`` for nginx mode)."""
49+
project = discover_project(output_dir)
50+
feature = get_feature("deploy")
51+
if feature is None: # pragma: no cover — built-in feature, always present
52+
error("deploy feature is not registered.")
53+
raise typer.Exit(code=1)
54+
55+
target_root = (output_dir or project.repo_root).resolve()
56+
target_root.mkdir(parents=True, exist_ok=True)
57+
58+
params: dict = {
59+
"mode": mode.value,
60+
"api_port": api_port,
61+
"workers": workers,
62+
"compose_target": target_root / "docker-compose.yml",
63+
}
64+
if mode == DeployMode.nginx:
65+
params["nginx_conf_target"] = target_root / "nginx" / "default.conf"
66+
67+
plan = feature.plan(params, project)
68+
69+
installer = FeatureInstaller(dry_run=dry_run, assume_yes=force or yes)
70+
info(f"deploy: generating '{mode.value}' compose for {project.repo_root}")
71+
result = installer.apply(plan)
72+
73+
if result.files_skipped:
74+
info("")
75+
info(f"{len(result.files_skipped)} file(s) skipped.")
76+
if dry_run:
77+
info("")
78+
info("dry-run complete — no files were written.")
79+
return
80+
81+
info("")
82+
info("done. Next steps:")
83+
if mode == DeployMode.local:
84+
info(" docker compose up --build")
85+
elif mode == DeployMode.prod:
86+
info(" cp backend/.env.example backend/.env # if you haven't already")
87+
info(" docker compose up -d --build")
88+
else:
89+
info(" cp backend/.env.example backend/.env # if you haven't already")
90+
info(" docker compose up -d --build")
91+
info(" curl -i http://localhost/api/v1/health")

0 commit comments

Comments
 (0)