paper-draft 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.
- paper/__init__.py +0 -0
- paper/draft/cli/__init__.py +0 -0
- paper/draft/cli/commands/__init__.py +2 -0
- paper/draft/cli/commands/init.py +204 -0
- paper/draft/cli/commands/new.py +204 -0
- paper/draft/cli/commands/run.py +101 -0
- paper/draft/cli/commands/validate.py +169 -0
- paper/draft/cli/main.py +23 -0
- paper_draft-0.1.0.dist-info/METADATA +468 -0
- paper_draft-0.1.0.dist-info/RECORD +13 -0
- paper_draft-0.1.0.dist-info/WHEEL +5 -0
- paper_draft-0.1.0.dist-info/entry_points.txt +2 -0
- paper_draft-0.1.0.dist-info/top_level.txt +1 -0
paper/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# PaperDraft CLI — init command
|
|
3
|
+
# File: paper/draft/cli/commands/init.py
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# Usage:
|
|
6
|
+
# draft init my-api
|
|
7
|
+
# draft init my-api --port 8000
|
|
8
|
+
#
|
|
9
|
+
# Creates:
|
|
10
|
+
# my-api/
|
|
11
|
+
# main.py, dependencies.py, config.py, requirements.txt,
|
|
12
|
+
# .gitignore, .env.dev, services/
|
|
13
|
+
#
|
|
14
|
+
# Then runs:
|
|
15
|
+
# python -m venv .venv
|
|
16
|
+
# pip install -r requirements.txt
|
|
17
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from jinja2 import Environment, FileSystemLoader
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.tree import Tree as RichTree
|
|
31
|
+
|
|
32
|
+
app: typer.Typer = typer.Typer(help="Initialise a new PaperDraft project.", no_args_is_help=True)
|
|
33
|
+
console: Console = Console()
|
|
34
|
+
err: Console = Console(stderr=True)
|
|
35
|
+
|
|
36
|
+
_TEMPLATES_DIR: Path = Path(__file__).parent.parent / "templates"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render(template_name: str, context: dict) -> str:
|
|
40
|
+
env = Environment(
|
|
41
|
+
loader = FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
42
|
+
trim_blocks = True,
|
|
43
|
+
lstrip_blocks = True,
|
|
44
|
+
)
|
|
45
|
+
return env.get_template(template_name).render(**context)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback(invoke_without_command=True)
|
|
49
|
+
def init_cmd(
|
|
50
|
+
name: Annotated[
|
|
51
|
+
str,
|
|
52
|
+
typer.Argument(help="Application name — also used as the root folder name.")
|
|
53
|
+
],
|
|
54
|
+
port: Annotated[
|
|
55
|
+
int,
|
|
56
|
+
typer.Option("--port", "-p", help="Port this service runs on.")
|
|
57
|
+
],
|
|
58
|
+
description: Annotated[
|
|
59
|
+
str,
|
|
60
|
+
typer.Option("--description", "-d", help="Application description.")
|
|
61
|
+
] = "PaperDraft REST API",
|
|
62
|
+
version: Annotated[
|
|
63
|
+
str,
|
|
64
|
+
typer.Option("--version", help="Application version.")
|
|
65
|
+
] = "1.0.0",
|
|
66
|
+
skip_install: Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option("--skip-install", help="Skip venv creation and package installation.")
|
|
69
|
+
] = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Initialise a new PaperDraft project in a new <name> directory.
|
|
73
|
+
|
|
74
|
+
Creates the project folder, scaffolds all root files and services/,
|
|
75
|
+
sets up a Python virtual environment, and installs all dependencies.
|
|
76
|
+
"""
|
|
77
|
+
package_name: str = name.lower().replace(" ", "-")
|
|
78
|
+
cwd: Path = Path.cwd() / package_name
|
|
79
|
+
|
|
80
|
+
# ── Guard — don't overwrite an existing project ───────────────────────────
|
|
81
|
+
if cwd.exists():
|
|
82
|
+
err.print(
|
|
83
|
+
f"[bold red]✗[/bold red] [yellow]{package_name}/[/yellow] already exists."
|
|
84
|
+
)
|
|
85
|
+
raise typer.Exit(code=2)
|
|
86
|
+
|
|
87
|
+
cwd.mkdir(parents=True)
|
|
88
|
+
context: dict = {
|
|
89
|
+
"app_name": name,
|
|
90
|
+
"app_description": description,
|
|
91
|
+
"app_version": version,
|
|
92
|
+
"package_name": package_name,
|
|
93
|
+
"port": port,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.print(f"\n[bold]Initialising[/bold] [green]{name}[/green]...\n")
|
|
97
|
+
created: list[str] = []
|
|
98
|
+
|
|
99
|
+
# ── Root files ────────────────────────────────────────────────────────────
|
|
100
|
+
def write(filename: str, template: str) -> None:
|
|
101
|
+
path: Path = cwd / filename
|
|
102
|
+
path.write_text(_render(template, context), encoding="utf-8")
|
|
103
|
+
created.append(filename)
|
|
104
|
+
|
|
105
|
+
write("main.py", "main.py.j2")
|
|
106
|
+
write("dependencies.py", "dependencies.py.j2")
|
|
107
|
+
write("config.py", "config.py.j2")
|
|
108
|
+
write(".gitignore", "gitignore.j2")
|
|
109
|
+
|
|
110
|
+
# requirements.txt — straight copy, no templating
|
|
111
|
+
req_src: Path = Path(__file__).parent.parent / "templates" / "requirements.txt"
|
|
112
|
+
if req_src.exists():
|
|
113
|
+
(cwd / "requirements.txt").write_text(req_src.read_text(), encoding="utf-8")
|
|
114
|
+
else:
|
|
115
|
+
(cwd / "requirements.txt").write_text("# Add your dependencies here\n", encoding="utf-8")
|
|
116
|
+
created.append("requirements.txt")
|
|
117
|
+
|
|
118
|
+
# .env stub
|
|
119
|
+
env_content: str = (
|
|
120
|
+
"ENV=dev\n"
|
|
121
|
+
"APP_URL=http://localhost:{port}\n"
|
|
122
|
+
"APP_PORT={port}\n\n"
|
|
123
|
+
"POSTGRES_CONN_STRING=\n"
|
|
124
|
+
"POSTGRES_ADMIN_CONN_STRING=\n\n"
|
|
125
|
+
"ENCRYPTION_PUBLIC_KEY=\n"
|
|
126
|
+
"ENCRYPTION_PRIVATE_KEY=\n\n"
|
|
127
|
+
"EMAIL_SMTP_HOST=\n"
|
|
128
|
+
"EMAIL_SMTP_PORT=587\n"
|
|
129
|
+
"EMAIL_SMTP_USERNAME=\n"
|
|
130
|
+
"EMAIL_SMTP_PASSWORD=\n"
|
|
131
|
+
"EMAIL_SENDER_NAME=\n"
|
|
132
|
+
"EMAIL_SENDER_ADDRESS=\n"
|
|
133
|
+
).format(port=port)
|
|
134
|
+
(cwd / ".env.dev").write_text(env_content, encoding="utf-8")
|
|
135
|
+
created.append(".env.dev")
|
|
136
|
+
|
|
137
|
+
# ── Directories ───────────────────────────────────────────────────────────
|
|
138
|
+
(cwd / "services").mkdir(exist_ok=True)
|
|
139
|
+
created.append("services/")
|
|
140
|
+
|
|
141
|
+
# ── Print scaffold tree ───────────────────────────────────────────────────
|
|
142
|
+
tree = RichTree(f"[bold]{package_name}/[/bold]")
|
|
143
|
+
for item in sorted(created):
|
|
144
|
+
if item.endswith("/"):
|
|
145
|
+
tree.add(f"[bold blue]{item}[/bold blue]")
|
|
146
|
+
elif item.startswith("."):
|
|
147
|
+
tree.add(f"[dim]{item}[/dim]")
|
|
148
|
+
elif item == "main.py":
|
|
149
|
+
tree.add(f"[bold green]{item}[/bold green]")
|
|
150
|
+
else:
|
|
151
|
+
tree.add(item)
|
|
152
|
+
console.print(tree)
|
|
153
|
+
console.print()
|
|
154
|
+
|
|
155
|
+
if skip_install:
|
|
156
|
+
console.print("[dim]Skipping installation (--skip-install)[/dim]")
|
|
157
|
+
_print_next_steps(name)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# ── Python venv ───────────────────────────────────────────────────────────
|
|
161
|
+
_run_step(
|
|
162
|
+
label = "Creating Python virtual environment",
|
|
163
|
+
command = [sys.executable, "-m", "venv", ".venv"],
|
|
164
|
+
cwd = cwd,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ── pip install ───────────────────────────────────────────────────────────
|
|
168
|
+
venv_pip: str = str(cwd / ".venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip")
|
|
169
|
+
_run_step(
|
|
170
|
+
label = "Installing Python packages",
|
|
171
|
+
command = [venv_pip, "install", "-r", "requirements.txt", "--quiet"],
|
|
172
|
+
cwd = cwd,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
_print_next_steps(name)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_step(label: str, command: list[str], cwd: Path) -> None:
|
|
179
|
+
console.print(f" [dim]{label}...[/dim]", end="")
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
command, cwd=cwd,
|
|
183
|
+
capture_output=True, text=True
|
|
184
|
+
)
|
|
185
|
+
if result.returncode == 0:
|
|
186
|
+
console.print(" [bold green]✓[/bold green]")
|
|
187
|
+
else:
|
|
188
|
+
console.print(" [bold red]✗[/bold red]")
|
|
189
|
+
err.print(f"[dim]{result.stderr.strip()}[/dim]")
|
|
190
|
+
except FileNotFoundError as e:
|
|
191
|
+
console.print(f" [bold yellow]skipped[/bold yellow] [dim]({e.filename} not found)[/dim]")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _print_next_steps(name: str) -> None:
|
|
195
|
+
package_name: str = name.lower().replace(" ", "-")
|
|
196
|
+
console.print(Panel(
|
|
197
|
+
f"[bold]Next steps:[/bold]\n\n"
|
|
198
|
+
f" 1. [bold]cd {package_name}[/bold]\n"
|
|
199
|
+
f" 2. Fill in [yellow].env.dev[/yellow] with your DB and encryption keys\n"
|
|
200
|
+
f" 3. Run [bold]draft new service [name][/bold] to add a service\n"
|
|
201
|
+
f" 4. Run [bold]draft run[/bold] to start the app",
|
|
202
|
+
title = f"[bold green]✓ {name} initialised[/bold green]",
|
|
203
|
+
border_style = "green",
|
|
204
|
+
))
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# PaperDraft CLI — new command
|
|
3
|
+
# File: paper/draft/cli/commands/new.py
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# Usage:
|
|
6
|
+
# draft new service location --prefix location
|
|
7
|
+
#
|
|
8
|
+
# Creates:
|
|
9
|
+
# services/location/
|
|
10
|
+
# __init__.py
|
|
11
|
+
# router.py
|
|
12
|
+
# controller.py
|
|
13
|
+
# service.py
|
|
14
|
+
#
|
|
15
|
+
# Then patches main.py:
|
|
16
|
+
# from services.location.router import router as location_router
|
|
17
|
+
# app.include_router(location_router, prefix="/location", tags=["Location"])
|
|
18
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from jinja2 import Environment, FileSystemLoader
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.tree import Tree as RichTree
|
|
31
|
+
|
|
32
|
+
app: typer.Typer = typer.Typer(help="Scaffold a new resource.", no_args_is_help=True)
|
|
33
|
+
console: Console = Console()
|
|
34
|
+
err: Console = Console(stderr=True)
|
|
35
|
+
|
|
36
|
+
_TEMPLATES_DIR: Path = Path(__file__).parent.parent / "templates"
|
|
37
|
+
|
|
38
|
+
# ── Markers written into main.py so we can patch it reliably ─────────────────
|
|
39
|
+
_ROUTERS_MARKER: str = "# ── Routers"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _render(template_name: str, context: dict) -> str:
|
|
43
|
+
env = Environment(
|
|
44
|
+
loader = FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
45
|
+
trim_blocks = True,
|
|
46
|
+
lstrip_blocks = True,
|
|
47
|
+
)
|
|
48
|
+
return env.get_template(template_name).render(**context)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_class_name(name: str) -> str:
|
|
52
|
+
return "".join(p.capitalize() for p in name.replace("-", "_").split("_"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("service")
|
|
56
|
+
def new_service(
|
|
57
|
+
name: Annotated[
|
|
58
|
+
str,
|
|
59
|
+
typer.Argument(help="Service name — e.g. location, user, product.")
|
|
60
|
+
],
|
|
61
|
+
prefix: Annotated[
|
|
62
|
+
str,
|
|
63
|
+
typer.Option("--prefix", "-p", help="URL prefix for this service — e.g. location, refs/cycle.")
|
|
64
|
+
] = "",
|
|
65
|
+
force: Annotated[
|
|
66
|
+
bool,
|
|
67
|
+
typer.Option("--force", "-f", help="Overwrite existing service files.")
|
|
68
|
+
] = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Scaffold a new service and register it in main.py.
|
|
72
|
+
|
|
73
|
+
Creates router.py, controller.py, service.py, and __init__.py
|
|
74
|
+
inside services/<name>/. Then patches main.py with the router import
|
|
75
|
+
and app.include_router() call.
|
|
76
|
+
"""
|
|
77
|
+
cwd: Path = Path.cwd()
|
|
78
|
+
|
|
79
|
+
# ── Validate project root ─────────────────────────────────────────────────
|
|
80
|
+
main_py: Path = cwd / "main.py"
|
|
81
|
+
if not main_py.exists():
|
|
82
|
+
err.print(
|
|
83
|
+
"[bold red]✗[/bold red] No [yellow]main.py[/yellow] found in current directory. "
|
|
84
|
+
"Run [bold]draft init[/bold] first."
|
|
85
|
+
)
|
|
86
|
+
raise typer.Exit(code=2)
|
|
87
|
+
|
|
88
|
+
name = name.strip().lower().replace(" ", "-")
|
|
89
|
+
prefix = prefix.strip().strip("/")
|
|
90
|
+
class_name = _to_class_name(name)
|
|
91
|
+
route_prefix = f"/{prefix}" if prefix else f"/{name}"
|
|
92
|
+
|
|
93
|
+
service_dir: Path = cwd / "services" / name
|
|
94
|
+
|
|
95
|
+
if service_dir.exists() and not force:
|
|
96
|
+
err.print(
|
|
97
|
+
f"[bold red]✗[/bold red] [yellow]services/{name}/[/yellow] already exists. "
|
|
98
|
+
f"Use [bold]--force[/bold] to overwrite."
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(code=2)
|
|
101
|
+
|
|
102
|
+
# ── Render and write service files ────────────────────────────────────────
|
|
103
|
+
context: dict = {
|
|
104
|
+
"service_name": name,
|
|
105
|
+
"class_name": class_name,
|
|
106
|
+
"prefix": route_prefix,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
(service_dir / "__init__.py").write_text("", encoding="utf-8")
|
|
111
|
+
(service_dir / "router.py").write_text(
|
|
112
|
+
_render("router.py.j2", context), encoding="utf-8"
|
|
113
|
+
)
|
|
114
|
+
(service_dir / "controller.py").write_text(
|
|
115
|
+
_render("controller.py.j2", context), encoding="utf-8"
|
|
116
|
+
)
|
|
117
|
+
(service_dir / "service.py").write_text(
|
|
118
|
+
_render("service.py.j2", context), encoding="utf-8"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ── Patch main.py ─────────────────────────────────────────────────────────
|
|
122
|
+
_patch_main(main_py, name, class_name, route_prefix)
|
|
123
|
+
|
|
124
|
+
# ── Print result ──────────────────────────────────────────────────────────
|
|
125
|
+
tree = RichTree(f"[bold]services/{name}/[/bold]")
|
|
126
|
+
tree.add("[dim]__init__.py[/dim]")
|
|
127
|
+
tree.add("[green]router.py[/green]")
|
|
128
|
+
tree.add("[green]controller.py[/green]")
|
|
129
|
+
tree.add("[green]service.py[/green]")
|
|
130
|
+
|
|
131
|
+
console.print()
|
|
132
|
+
console.print(tree)
|
|
133
|
+
console.print()
|
|
134
|
+
console.print(Panel(
|
|
135
|
+
f"[bold]Service:[/bold] {name}\n"
|
|
136
|
+
f"[bold]Prefix:[/bold] {route_prefix}\n"
|
|
137
|
+
f"[bold]Router:[/bold] registered in main.py\n\n"
|
|
138
|
+
f"Next: define your routes in "
|
|
139
|
+
f"[yellow]services/{name}/router.py[/yellow]",
|
|
140
|
+
title = f"[bold green]✓ {class_name} service created[/bold green]",
|
|
141
|
+
border_style = "green",
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _patch_main(main_py: Path, name: str, class_name: str, prefix: str) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Insert the import and include_router() call into main.py.
|
|
148
|
+
Uses the # ── Routers marker as the insertion point.
|
|
149
|
+
Idempotent — won't add duplicates.
|
|
150
|
+
"""
|
|
151
|
+
content: str = main_py.read_text(encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
import_line: str = (
|
|
154
|
+
f"from services.{name}.router import router as {name}_router"
|
|
155
|
+
)
|
|
156
|
+
router_line: str = (
|
|
157
|
+
f'app.include_router({name}_router, prefix="{prefix}", tags=["{class_name}"])'
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Skip if already registered
|
|
161
|
+
if import_line in content:
|
|
162
|
+
console.print(
|
|
163
|
+
f" [dim]main.py — {name} already registered, skipping[/dim]"
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Insert import after the last existing service import
|
|
168
|
+
# Pattern: find the last "from services." import line and insert after it,
|
|
169
|
+
# or insert right after the # ── Routers marker if no service imports yet
|
|
170
|
+
lines: list[str] = content.splitlines(keepends=True)
|
|
171
|
+
insert_import_at: int = -1
|
|
172
|
+
insert_router_at: int = -1
|
|
173
|
+
|
|
174
|
+
for i, line in enumerate(lines):
|
|
175
|
+
if line.startswith("from services.") and "router" in line:
|
|
176
|
+
insert_import_at = i # keep updating → lands after last one
|
|
177
|
+
|
|
178
|
+
if insert_import_at >= 0:
|
|
179
|
+
# Insert import after the last service import line
|
|
180
|
+
lines.insert(insert_import_at + 1, import_line + "\n")
|
|
181
|
+
# After insertion, router block shifted by 1
|
|
182
|
+
insert_router_at = insert_import_at + 2
|
|
183
|
+
else:
|
|
184
|
+
# No service imports yet — insert after the Routers marker comment
|
|
185
|
+
for i, line in enumerate(lines):
|
|
186
|
+
if line.startswith(_ROUTERS_MARKER):
|
|
187
|
+
lines.insert(i + 1, import_line + "\n")
|
|
188
|
+
insert_router_at = i + 2
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
# Now find the right place for the include_router call
|
|
192
|
+
# Insert after the last app.include_router(...) line
|
|
193
|
+
last_router_line: int = -1
|
|
194
|
+
for i, line in enumerate(lines):
|
|
195
|
+
if "app.include_router(" in line:
|
|
196
|
+
last_router_line = i
|
|
197
|
+
|
|
198
|
+
if last_router_line >= 0:
|
|
199
|
+
lines.insert(last_router_line + 1, router_line + "\n")
|
|
200
|
+
elif insert_router_at >= 0:
|
|
201
|
+
lines.insert(insert_router_at, "\n" + router_line + "\n")
|
|
202
|
+
|
|
203
|
+
main_py.write_text("".join(lines), encoding="utf-8")
|
|
204
|
+
console.print(f" [dim]main.py — {name} router registered at {prefix}[/dim]")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# PaperDraft CLI — run command
|
|
3
|
+
# File: paper/draft/cli/commands/run.py
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# Usage:
|
|
6
|
+
# draft run ← uvicorn with auto-reload (dev)
|
|
7
|
+
# draft run --prod ← uvicorn with 4 workers, no reload (production)
|
|
8
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.rule import Rule
|
|
18
|
+
|
|
19
|
+
app: typer.Typer = typer.Typer(help="Run the application.", no_args_is_help=False)
|
|
20
|
+
console: Console = Console()
|
|
21
|
+
err: Console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def run_cmd(
|
|
26
|
+
prod: Annotated[
|
|
27
|
+
bool,
|
|
28
|
+
typer.Option("--prod", help="Production mode — 4 workers, no reload.")
|
|
29
|
+
] = False,
|
|
30
|
+
host: Annotated[
|
|
31
|
+
str,
|
|
32
|
+
typer.Option("--host", help="Bind host.")
|
|
33
|
+
] = "0.0.0.0",
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Run the PaperDraft application with uvicorn.
|
|
37
|
+
|
|
38
|
+
Port is read from APP_PORT in main.py.
|
|
39
|
+
Default: auto-reload enabled (dev).
|
|
40
|
+
--prod: 4 workers, no reload (production).
|
|
41
|
+
"""
|
|
42
|
+
cwd: Path = Path.cwd()
|
|
43
|
+
|
|
44
|
+
main_py: Path = cwd / "main.py"
|
|
45
|
+
if not main_py.exists():
|
|
46
|
+
err.print(
|
|
47
|
+
"[bold red]✗[/bold red] No [yellow]main.py[/yellow] found. "
|
|
48
|
+
"Run [bold]draft init[/bold] first."
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(code=2)
|
|
51
|
+
|
|
52
|
+
port: int = _read_port(cwd)
|
|
53
|
+
|
|
54
|
+
mode: str = "production" if prod else "dev"
|
|
55
|
+
reload: bool = not prod
|
|
56
|
+
workers: int = 4 if prod else 1
|
|
57
|
+
|
|
58
|
+
console.print()
|
|
59
|
+
console.print(Rule(f"[bold green]PaperDraft[/bold green] — {mode}"))
|
|
60
|
+
console.print(f" [dim]Address:[/dim] http://{host}:{port}")
|
|
61
|
+
console.print(f" [dim]Reload:[/dim] {reload}")
|
|
62
|
+
if prod:
|
|
63
|
+
console.print(f" [dim]Workers:[/dim] {workers}")
|
|
64
|
+
console.print(Rule())
|
|
65
|
+
console.print()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
import uvicorn
|
|
69
|
+
uvicorn.run("main:app", host=host, port=port, reload=reload, workers=workers if prod else None)
|
|
70
|
+
except ImportError:
|
|
71
|
+
err.print(
|
|
72
|
+
"[bold red]✗[/bold red] uvicorn not installed. "
|
|
73
|
+
"Run [bold]pip install uvicorn[/bold]."
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(code=3)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_port(cwd: Path) -> int:
|
|
79
|
+
"""
|
|
80
|
+
Read APP_PORT from the active .env file.
|
|
81
|
+
Checks .env for ENV= to determine which .env.{env} file to load,
|
|
82
|
+
then reads APP_PORT from it. Falls back to 8000 if not found.
|
|
83
|
+
"""
|
|
84
|
+
env: str = "dev"
|
|
85
|
+
base_env: Path = cwd / ".env"
|
|
86
|
+
if base_env.exists():
|
|
87
|
+
for line in base_env.read_text(encoding="utf-8").splitlines():
|
|
88
|
+
if line.startswith("ENV="):
|
|
89
|
+
env = line.split("=", 1)[1].strip()
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
env_file: Path = cwd / f".env.{env}"
|
|
93
|
+
if env_file.exists():
|
|
94
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
95
|
+
if line.startswith("APP_PORT="):
|
|
96
|
+
value: str = line.split("=", 1)[1].strip()
|
|
97
|
+
if value.isdigit():
|
|
98
|
+
return int(value)
|
|
99
|
+
|
|
100
|
+
err.print("[bold yellow]![/bold yellow] APP_PORT not found in env file — defaulting to 8000.")
|
|
101
|
+
return 8000
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# PaperDraft CLI — validate command
|
|
3
|
+
# File: paper/draft/cli/commands/validate.py
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# Usage:
|
|
6
|
+
# draft validate location
|
|
7
|
+
# draft validate location --quiet
|
|
8
|
+
#
|
|
9
|
+
# Checks:
|
|
10
|
+
# 1. services/<n>/ folder exists
|
|
11
|
+
# 2. router.py, controller.py, service.py, __init__.py all present
|
|
12
|
+
# 3. router.py imports the controller
|
|
13
|
+
# 4. service.py defines the service class
|
|
14
|
+
# 5. controller.py defines the controller class
|
|
15
|
+
# 6. main.py imports and registers this router
|
|
16
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Annotated
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
|
|
28
|
+
app: typer.Typer = typer.Typer(help="Validate a service.", no_args_is_help=True)
|
|
29
|
+
console: Console = Console()
|
|
30
|
+
err: Console = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_class_name(name: str) -> str:
|
|
34
|
+
return "".join(p.capitalize() for p in name.replace("-", "_").split("_"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback(invoke_without_command=True)
|
|
38
|
+
def validate_cmd(
|
|
39
|
+
service: Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Argument(help="Service name to validate — e.g. location, user.")
|
|
42
|
+
],
|
|
43
|
+
quiet: Annotated[
|
|
44
|
+
bool,
|
|
45
|
+
typer.Option("--quiet", "-q", help="Suppress output on success.")
|
|
46
|
+
] = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Validate a service — checks file structure and main.py registration.
|
|
50
|
+
|
|
51
|
+
Exits 0 on success, 1 on validation errors, 2 on missing project root.
|
|
52
|
+
"""
|
|
53
|
+
cwd: Path = Path.cwd()
|
|
54
|
+
|
|
55
|
+
# ── Project root check ────────────────────────────────────────────────────
|
|
56
|
+
main_py: Path = cwd / "main.py"
|
|
57
|
+
if not main_py.exists():
|
|
58
|
+
err.print(
|
|
59
|
+
"[bold red]✗[/bold red] No [yellow]main.py[/yellow] found. "
|
|
60
|
+
"Run [bold]draft init[/bold] first."
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(code=2)
|
|
63
|
+
|
|
64
|
+
service = service.strip().lower()
|
|
65
|
+
class_name = _to_class_name(service)
|
|
66
|
+
svc_dir: Path = cwd / "services" / service
|
|
67
|
+
|
|
68
|
+
issues: list[tuple[str, str]] = [] # (check, message)
|
|
69
|
+
passes: list[str] = []
|
|
70
|
+
|
|
71
|
+
# ── 1. Service directory ──────────────────────────────────────────────────
|
|
72
|
+
if not svc_dir.exists():
|
|
73
|
+
issues.append(("directory", f"services/{service}/ does not exist"))
|
|
74
|
+
else:
|
|
75
|
+
passes.append(f"services/{service}/ exists")
|
|
76
|
+
|
|
77
|
+
# ── 2. Required files ─────────────────────────────────────────────────────
|
|
78
|
+
required_files: list[str] = [
|
|
79
|
+
"__init__.py", "router.py", "controller.py", "service.py"
|
|
80
|
+
]
|
|
81
|
+
for fname in required_files:
|
|
82
|
+
fpath: Path = svc_dir / fname
|
|
83
|
+
if not fpath.exists():
|
|
84
|
+
issues.append((fname, f"services/{service}/{fname} is missing"))
|
|
85
|
+
else:
|
|
86
|
+
passes.append(f"services/{service}/{fname} exists")
|
|
87
|
+
|
|
88
|
+
# ── 3. router.py — imports controller ────────────────────────────────────
|
|
89
|
+
router_path: Path = svc_dir / "router.py"
|
|
90
|
+
if router_path.exists():
|
|
91
|
+
router_content: str = router_path.read_text(encoding="utf-8")
|
|
92
|
+
if f"{class_name}Controller" in router_content:
|
|
93
|
+
passes.append(f"router.py references {class_name}Controller")
|
|
94
|
+
else:
|
|
95
|
+
issues.append((
|
|
96
|
+
"router.py",
|
|
97
|
+
f"{class_name}Controller not referenced in router.py"
|
|
98
|
+
))
|
|
99
|
+
|
|
100
|
+
# ── 4. controller.py — defines controller class ───────────────────────────
|
|
101
|
+
ctrl_path: Path = svc_dir / "controller.py"
|
|
102
|
+
if ctrl_path.exists():
|
|
103
|
+
ctrl_content: str = ctrl_path.read_text(encoding="utf-8")
|
|
104
|
+
if f"class {class_name}Controller" in ctrl_content:
|
|
105
|
+
passes.append(f"controller.py defines {class_name}Controller")
|
|
106
|
+
else:
|
|
107
|
+
issues.append((
|
|
108
|
+
"controller.py",
|
|
109
|
+
f"class {class_name}Controller not found in controller.py"
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
# ── 5. service.py — defines service class ────────────────────────────────
|
|
113
|
+
svc_path: Path = svc_dir / "service.py"
|
|
114
|
+
if svc_path.exists():
|
|
115
|
+
svc_content: str = svc_path.read_text(encoding="utf-8")
|
|
116
|
+
if f"class {class_name}Service" in svc_content:
|
|
117
|
+
passes.append(f"service.py defines {class_name}Service")
|
|
118
|
+
else:
|
|
119
|
+
issues.append((
|
|
120
|
+
"service.py",
|
|
121
|
+
f"class {class_name}Service not found in service.py"
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
# ── 6. main.py — router imported and registered ───────────────────────────
|
|
125
|
+
main_content: str = main_py.read_text(encoding="utf-8")
|
|
126
|
+
import_line: str = f"from services.{service}.router import router"
|
|
127
|
+
router_line: str = f"app.include_router({service}_router"
|
|
128
|
+
|
|
129
|
+
if import_line in main_content:
|
|
130
|
+
passes.append(f"main.py imports services.{service}.router")
|
|
131
|
+
else:
|
|
132
|
+
issues.append((
|
|
133
|
+
"main.py",
|
|
134
|
+
f"services.{service}.router not imported in main.py"
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
if router_line in main_content:
|
|
138
|
+
passes.append(f"main.py registers {service}_router")
|
|
139
|
+
else:
|
|
140
|
+
issues.append((
|
|
141
|
+
"main.py",
|
|
142
|
+
f"{service}_router not registered via app.include_router() in main.py"
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
# ── Output ────────────────────────────────────────────────────────────────
|
|
146
|
+
valid: bool = len(issues) == 0
|
|
147
|
+
|
|
148
|
+
if valid and not quiet:
|
|
149
|
+
console.print(
|
|
150
|
+
f"[bold green]✓[/bold green] [bold]{service}[/bold] — "
|
|
151
|
+
f"valid, {len(passes)} checks passed."
|
|
152
|
+
)
|
|
153
|
+
raise typer.Exit(code=0)
|
|
154
|
+
|
|
155
|
+
if not valid:
|
|
156
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
157
|
+
for check, msg in issues:
|
|
158
|
+
table.add_row(
|
|
159
|
+
f"[bold red]✗[/bold red]",
|
|
160
|
+
f"[bold]{check}[/bold]",
|
|
161
|
+
f"[dim]{msg}[/dim]"
|
|
162
|
+
)
|
|
163
|
+
console.print(Panel(
|
|
164
|
+
table,
|
|
165
|
+
title = f"[bold red]Validation failed — {len(issues)} issue(s)[/bold red]",
|
|
166
|
+
border_style = "red",
|
|
167
|
+
subtitle = service,
|
|
168
|
+
))
|
|
169
|
+
raise typer.Exit(code=1)
|
paper/draft/cli/main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# PaperDraft CLI v0.1
|
|
3
|
+
# File: paper/draft/cli/main.py
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from paper.draft.cli.commands import init, new, validate, run
|
|
8
|
+
|
|
9
|
+
app: typer.Typer = typer.Typer(
|
|
10
|
+
name = "draft",
|
|
11
|
+
help = "PaperDraft — opinionated REST API framework.",
|
|
12
|
+
add_completion = True,
|
|
13
|
+
no_args_is_help = True,
|
|
14
|
+
rich_markup_mode = "rich",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app.add_typer(init.app, name="init")
|
|
18
|
+
app.add_typer(new.app, name="new")
|
|
19
|
+
app.add_typer(validate.app, name="validate")
|
|
20
|
+
app.add_typer(run.app, name="run")
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
app()
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paper-draft
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Opinionated FastAPI toolkit with CLI-driven architecture
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: paper-core
|
|
8
|
+
Requires-Dist: typer
|
|
9
|
+
Requires-Dist: jinja2
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# PaperDraft
|
|
17
|
+
|
|
18
|
+
Opinionated Python REST API framework with CLI-driven architecture.
|
|
19
|
+
|
|
20
|
+
PaperDraft scaffolds projects and services with an enforced layered structure, and ships a production-ready core library covering auth, database, encryption, email, middleware, and more.
|
|
21
|
+
|
|
22
|
+
> **Status:** pre-release · v0.1 in progress · Paper Plane Consulting LLC
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Table of Contents
|
|
27
|
+
|
|
28
|
+
1. [Installation](#installation)
|
|
29
|
+
2. [Setup](#setup)
|
|
30
|
+
3. [CLI Reference](#cli-reference)
|
|
31
|
+
4. [Building with paper-draft](#building-with-paper-draft)
|
|
32
|
+
5. [Project Structure](#project-structure)
|
|
33
|
+
6. [Extending paper-draft](#extending-paper-draft)
|
|
34
|
+
7. [Architecture](#architecture)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Packages
|
|
39
|
+
|
|
40
|
+
PaperDraft is split into two packages:
|
|
41
|
+
|
|
42
|
+
| Package | Purpose |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `paper-core` | Runtime library — auth, DB, security, middleware, email, errors, audit |
|
|
45
|
+
| `paper-draft` | CLI tooling — project scaffolding and service generation |
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
**Requirements:** Python 3.11+
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install paper-draft
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`paper-core` is installed automatically as a dependency.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Local Development Setup
|
|
62
|
+
|
|
63
|
+
**Prerequisites:** Python 3.11+
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 1. Clone the CLI repo
|
|
67
|
+
git clone https://flypaperplane@bitbucket.org/ppc-llc/paper-draft.git
|
|
68
|
+
cd paper-draft
|
|
69
|
+
|
|
70
|
+
# 2. Create and activate a virtual environment
|
|
71
|
+
python -m venv .venv
|
|
72
|
+
source .venv/bin/activate # macOS/Linux
|
|
73
|
+
.venv\Scripts\activate # Windows
|
|
74
|
+
|
|
75
|
+
# 3. Install paper-core
|
|
76
|
+
pip install paper-core
|
|
77
|
+
|
|
78
|
+
# 4. Install paper-draft CLI in editable mode with dev dependencies
|
|
79
|
+
pip install -e ".[dev]"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Once installed, the `draft` CLI is available in your environment:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
draft --help
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Setup
|
|
91
|
+
|
|
92
|
+
### Environment Files
|
|
93
|
+
|
|
94
|
+
PaperDraft uses layered `.env` files. The base `.env` sets the active environment, and the environment-specific file is loaded on top.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
.env ← sets ENV=dev (or staging, production)
|
|
98
|
+
.env.dev ← development values (auto-created by draft init)
|
|
99
|
+
.env.staging ← staging values (create manually)
|
|
100
|
+
.env.production ← production values (create manually)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**`.env`** (minimal — sets the active environment):
|
|
104
|
+
```env
|
|
105
|
+
ENV=dev
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**`.env.dev`** (created automatically by `draft init`):
|
|
109
|
+
```env
|
|
110
|
+
ENV=dev
|
|
111
|
+
APP_URL=http://localhost:8000
|
|
112
|
+
APP_PORT=8000
|
|
113
|
+
|
|
114
|
+
POSTGRES_CONN_STRING=postgresql+asyncpg://user:password@localhost:5432/dbname
|
|
115
|
+
POSTGRES_ADMIN_CONN_STRING=postgresql+asyncpg://user:password@localhost:5432/postgres
|
|
116
|
+
|
|
117
|
+
ENCRYPTION_PUBLIC_KEY=<base64-encoded PEM public key>
|
|
118
|
+
ENCRYPTION_PRIVATE_KEY=<base64-encoded PEM private key>
|
|
119
|
+
|
|
120
|
+
EMAIL_SMTP_HOST=smtp.example.com
|
|
121
|
+
EMAIL_SMTP_PORT=587
|
|
122
|
+
EMAIL_SMTP_USERNAME=your@email.com
|
|
123
|
+
EMAIL_SMTP_PASSWORD=your-smtp-password
|
|
124
|
+
EMAIL_SENDER_NAME=Your App
|
|
125
|
+
EMAIL_SENDER_ADDRESS=no-reply@example.com
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Generating Encryption Keys
|
|
129
|
+
|
|
130
|
+
PaperDraft uses RSA key pairs (RS256) for JWT signing and field-level encryption.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# Generate a 2048-bit RSA private key
|
|
134
|
+
openssl genrsa -out private.pem 2048
|
|
135
|
+
|
|
136
|
+
# Derive the public key
|
|
137
|
+
openssl rsa -in private.pem -pubout -out public.pem
|
|
138
|
+
|
|
139
|
+
# Base64-encode each for .env storage
|
|
140
|
+
base64 -w 0 private.pem # → ENCRYPTION_PRIVATE_KEY
|
|
141
|
+
base64 -w 0 public.pem # → ENCRYPTION_PUBLIC_KEY
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## CLI Reference
|
|
147
|
+
|
|
148
|
+
### `draft init <name> --port <port>`
|
|
149
|
+
|
|
150
|
+
Creates a new project in a `<name>/` directory.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
draft init my-api --port 8000
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Creates:**
|
|
157
|
+
```
|
|
158
|
+
my-api/
|
|
159
|
+
├── main.py # FastAPI app entry point
|
|
160
|
+
├── dependencies.py # DB + auth dependency injection
|
|
161
|
+
├── config.py # Pydantic Settings configuration
|
|
162
|
+
├── requirements.txt # App dependencies
|
|
163
|
+
├── .gitignore
|
|
164
|
+
├── .env.dev # Pre-filled environment stub
|
|
165
|
+
├── services/ # Your services go here
|
|
166
|
+
└── .venv/ # Python virtual environment (auto-created)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Then:**
|
|
170
|
+
```bash
|
|
171
|
+
cd my-api
|
|
172
|
+
# Fill in .env.dev with your DB connection string and keys
|
|
173
|
+
# Activate the venv: source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### `draft new service <name>`
|
|
179
|
+
|
|
180
|
+
Scaffolds a new service inside an existing project.
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
draft new service users
|
|
184
|
+
draft new service users --prefix api/v1/users # custom URL prefix
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Creates:**
|
|
188
|
+
```
|
|
189
|
+
services/users/
|
|
190
|
+
├── __init__.py
|
|
191
|
+
├── router.py # FastAPI APIRouter — define your routes here
|
|
192
|
+
├── controller.py # UsersController — validate + orchestrate
|
|
193
|
+
└── service.py # UsersService — business logic
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Also patches `main.py` to import and register the router automatically.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
### `draft validate <service>`
|
|
201
|
+
|
|
202
|
+
Validates a service's structure and `main.py` registration.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
draft validate users
|
|
206
|
+
draft validate users --quiet # suppress output on success
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Checks:**
|
|
210
|
+
- `services/users/` directory exists
|
|
211
|
+
- `router.py`, `controller.py`, `service.py`, `__init__.py` all present
|
|
212
|
+
- `router.py` references `UsersController`
|
|
213
|
+
- `controller.py` defines `class UsersController`
|
|
214
|
+
- `service.py` defines `class UsersService`
|
|
215
|
+
- `main.py` imports and registers the router
|
|
216
|
+
|
|
217
|
+
Exits `0` on success, `1` on validation errors, `2` if not in a project root.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### `draft run`
|
|
222
|
+
|
|
223
|
+
Runs the app with uvicorn. Port is read from `APP_PORT` in your `.env.{ENV}` file.
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
draft run # dev mode — auto-reload enabled
|
|
227
|
+
draft run --prod # production — 4 workers, no reload
|
|
228
|
+
draft run --host 0.0.0.0 --prod
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Building with PaperDraft
|
|
234
|
+
|
|
235
|
+
### Workflow
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
# 1. Create a new project
|
|
239
|
+
draft init my-api --port 8000
|
|
240
|
+
cd my-api
|
|
241
|
+
|
|
242
|
+
# 2. Fill in .env.dev
|
|
243
|
+
|
|
244
|
+
# 3. Add a service
|
|
245
|
+
draft new service users
|
|
246
|
+
|
|
247
|
+
# 4. Define your routes in services/users/router.py
|
|
248
|
+
# 5. Implement controller logic in services/users/controller.py
|
|
249
|
+
# 6. Implement business logic in services/users/service.py
|
|
250
|
+
|
|
251
|
+
# 7. Validate
|
|
252
|
+
draft validate users
|
|
253
|
+
|
|
254
|
+
# 8. Run
|
|
255
|
+
draft run
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Layer Responsibilities
|
|
259
|
+
|
|
260
|
+
Each scaffolded service has three layers with strict responsibilities:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
router.py ← HTTP only. Receives request, injects dependencies, returns response.
|
|
264
|
+
No logic. No direct DB access.
|
|
265
|
+
|
|
266
|
+
controller.py ← Validates the request. Orchestrates service calls.
|
|
267
|
+
No business logic. No direct DB access.
|
|
268
|
+
|
|
269
|
+
service.py ← All business logic lives here.
|
|
270
|
+
Calls the DB via injected Postgres instance.
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Dependency Injection
|
|
274
|
+
|
|
275
|
+
`dependencies.py` wires up DB and config — inject them directly in route signatures:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from dependencies import MasterDb, TenantDb, Configuration
|
|
279
|
+
|
|
280
|
+
@router.get("")
|
|
281
|
+
async def retrieve(
|
|
282
|
+
db: MasterDb,
|
|
283
|
+
config: Configuration,
|
|
284
|
+
claims: Dict[str, Any] = Depends(Authenticate())
|
|
285
|
+
):
|
|
286
|
+
return await UsersController.retrieve(db, config, claims)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Auth
|
|
290
|
+
|
|
291
|
+
Use `Authenticate` for any valid JWT, `Authorize` for role enforcement:
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
from paper.core.auth import Authenticate, Authorize
|
|
295
|
+
|
|
296
|
+
# Any authenticated user
|
|
297
|
+
claims = Depends(Authenticate())
|
|
298
|
+
|
|
299
|
+
# Role-restricted
|
|
300
|
+
claims = Depends(Authorize(["admin", "manager"]))
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Project Structure
|
|
306
|
+
|
|
307
|
+
A full PaperDraft project looks like this:
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
my-api/
|
|
311
|
+
├── main.py # FastAPI app, middleware, router registration
|
|
312
|
+
├── dependencies.py # DB + auth dependency wiring
|
|
313
|
+
├── config.py # Pydantic Settings
|
|
314
|
+
├── requirements.txt
|
|
315
|
+
├── .env # Sets ENV=dev|staging|production
|
|
316
|
+
├── .env.dev # Dev config (never commit)
|
|
317
|
+
├── services/
|
|
318
|
+
│ ├── users/
|
|
319
|
+
│ │ ├── router.py
|
|
320
|
+
│ │ ├── controller.py
|
|
321
|
+
│ │ └── service.py
|
|
322
|
+
│ └── auth/
|
|
323
|
+
│ ├── router.py
|
|
324
|
+
│ ├── controller.py
|
|
325
|
+
│ └── service.py
|
|
326
|
+
└── .venv/
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Extending PaperDraft
|
|
332
|
+
|
|
333
|
+
PaperDraft's core components are built on abstract base classes. Extend them to add new database engines, encryption algorithms, or email providers while keeping the same interface everywhere.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
### Custom Database Engine
|
|
338
|
+
|
|
339
|
+
Extend `Repository` to add a new DB engine (MySQL, MongoDB, etc.).
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from paper.core.db.base import Repository, FilterType
|
|
343
|
+
from typing import Any, Dict, List, Optional
|
|
344
|
+
|
|
345
|
+
class MySQLRepository(Repository[T, M]):
|
|
346
|
+
|
|
347
|
+
def __init__(self, connection_string: str) -> None:
|
|
348
|
+
# set up your engine here
|
|
349
|
+
...
|
|
350
|
+
|
|
351
|
+
async def create(self, entity, model, data):
|
|
352
|
+
...
|
|
353
|
+
|
|
354
|
+
async def retrieve(self, entity, model, filter=None):
|
|
355
|
+
...
|
|
356
|
+
|
|
357
|
+
async def single(self, entity, model, id):
|
|
358
|
+
...
|
|
359
|
+
|
|
360
|
+
async def update(self, entity, model, id, data):
|
|
361
|
+
...
|
|
362
|
+
|
|
363
|
+
async def delete(self, entity, id):
|
|
364
|
+
...
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Inject it the same way as `Postgres` via `dependencies.py`.
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### Custom Encryption Algorithm
|
|
372
|
+
|
|
373
|
+
Extend `BaseCrypto` to add a new cipher (AES, ChaCha20, etc.).
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from paper.core.security.base import BaseCrypto
|
|
377
|
+
|
|
378
|
+
class AESCrypto(BaseCrypto):
|
|
379
|
+
|
|
380
|
+
def __init__(self, key: str) -> None:
|
|
381
|
+
self._key = key
|
|
382
|
+
|
|
383
|
+
def encrypt(self, value: str) -> str:
|
|
384
|
+
...
|
|
385
|
+
|
|
386
|
+
def decrypt(self, cipher: str) -> str:
|
|
387
|
+
...
|
|
388
|
+
|
|
389
|
+
def encrypt_urlsafe(self, value: str) -> str:
|
|
390
|
+
...
|
|
391
|
+
|
|
392
|
+
def decrypt_urlsafe(self, cipher: str) -> str:
|
|
393
|
+
...
|
|
394
|
+
|
|
395
|
+
def encrypt_raw(self, value: str) -> bytes:
|
|
396
|
+
...
|
|
397
|
+
|
|
398
|
+
def decrypt_raw(self, cipher_bytes: bytes) -> str:
|
|
399
|
+
...
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Pass your implementation to `MultiTenantDbDependency` or anywhere `BaseCrypto` is accepted:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
crypto = AESCrypto(key=config.ENCRYPTION.KEY.SYMMETRIC)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
### Custom Email Provider
|
|
411
|
+
|
|
412
|
+
Extend `BaseEmailService` to add a new provider (SendGrid, SES, Postmark, etc.).
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
from paper.core.email.base import BaseEmailService
|
|
416
|
+
from typing import Dict
|
|
417
|
+
|
|
418
|
+
class SendGridEmailService(BaseEmailService):
|
|
419
|
+
|
|
420
|
+
def __init__(self, api_key: str, sender_name: str, sender_email: str) -> None:
|
|
421
|
+
self._api_key = api_key
|
|
422
|
+
self._sender_name = sender_name
|
|
423
|
+
self._sender_email = sender_email
|
|
424
|
+
|
|
425
|
+
def send(
|
|
426
|
+
self,
|
|
427
|
+
subject: str,
|
|
428
|
+
recipient_name: str,
|
|
429
|
+
recipient_email: str,
|
|
430
|
+
data: Dict[str, str],
|
|
431
|
+
) -> bool:
|
|
432
|
+
# implement SendGrid API call here
|
|
433
|
+
...
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Instantiate in `dependencies.py` and inject wherever email is needed.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Architecture
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
HTTP Request
|
|
444
|
+
↓
|
|
445
|
+
[ Router ] receives request, injects dependencies, returns response
|
|
446
|
+
↓
|
|
447
|
+
[ Auth Middleware ] JWT validation + RBAC — always first, never skipped
|
|
448
|
+
↓
|
|
449
|
+
[ Custom Middleware ] CORS, HIPAA headers, rate limiting, audit logging
|
|
450
|
+
↓
|
|
451
|
+
[ Controller ] validates request, orchestrates service calls
|
|
452
|
+
↓
|
|
453
|
+
[ Service ] all business logic lives here
|
|
454
|
+
↓
|
|
455
|
+
[ Postgres / DB ] async SQLAlchemy — injected, never instantiated directly
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Core modules available via `paper.core`:**
|
|
459
|
+
|
|
460
|
+
| Module | Contents |
|
|
461
|
+
|--------|----------|
|
|
462
|
+
| `paper.core.auth` | `Authenticate`, `Authorize`, `LoginAttemptLimit`, `Password`, JWT utils |
|
|
463
|
+
| `paper.core.db` | `Postgres`, `Repository`, `FilterType`, `MultiTenantDbDependency` |
|
|
464
|
+
| `paper.core.security` | `RSACrypto`, `BaseCrypto`, `Crypto`, `Hasher`, `Pem` |
|
|
465
|
+
| `paper.core.email` | `SMTPEmailService`, `BaseEmailService`, `Subject`, `EmailTheme` |
|
|
466
|
+
| `paper.core.errors` | `ErrorHandler`, `ErrorMessage` |
|
|
467
|
+
| `paper.core.middleware` | `HipaaResponseHeaders`, `RequestLoggingMiddleware`, `RequestIdMiddleware` |
|
|
468
|
+
| `paper.core.audit` | `Audit` |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
paper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
paper/draft/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
paper/draft/cli/main.py,sha256=CdqGFqlx8hq5eNtJ2-gkyq7Qtq8s0cU8inDK35Wonl4,1032
|
|
4
|
+
paper/draft/cli/commands/__init__.py,sha256=1G1KmELqWS3iPgM3-KmUNtpEYdgh97jrHRkw6wrDumM,106
|
|
5
|
+
paper/draft/cli/commands/init.py,sha256=fwKp7q2qJ7BRjTDDyB3FbQE_0dwY6-EcL43xuS7_w24,8203
|
|
6
|
+
paper/draft/cli/commands/new.py,sha256=cfi7p8nJwadl9JJbFaw_oyj31gH6War01frVaGmFZec,8137
|
|
7
|
+
paper/draft/cli/commands/run.py,sha256=WYc72KME4WkXcgZG6E2_tN0UnCTl2zLSwIek3jB0nC4,3789
|
|
8
|
+
paper/draft/cli/commands/validate.py,sha256=EWE0QHq5DwSCjhdrkJdSKmw0zMEg6ebsEgYxVTsEqWg,7307
|
|
9
|
+
paper_draft-0.1.0.dist-info/METADATA,sha256=Gl4I8TTlf7mr_uylIlf5qnLw3TVavhaoha6wDDLFPtM,12223
|
|
10
|
+
paper_draft-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
paper_draft-0.1.0.dist-info/entry_points.txt,sha256=ZXlns9uk-lu9KyszQ5D7ZJxUo8oXIBhPJv4l0fjTLXg,51
|
|
12
|
+
paper_draft-0.1.0.dist-info/top_level.txt,sha256=4NSK5piDx1QcezDlTFhYZtyixZ0wEqRP_fvBQex6byk,6
|
|
13
|
+
paper_draft-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paper
|