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 ADDED
File without changes
File without changes
@@ -0,0 +1,2 @@
1
+ from paper.draft.cli.commands import init, new, validate, run
2
+ __all__ = ["init", "new", "validate", "run"]
@@ -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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ draft = paper.draft.cli.main:app
@@ -0,0 +1 @@
1
+ paper