function-api-builder 0.1.0__tar.gz

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.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: function-api-builder
3
+ Version: 0.1.0
4
+ Summary: Generate complete FastAPI applications from decorated Python business functions.
5
+ Author: Sushruth Samson
6
+ License-Expression: MIT
7
+ Keywords: api-builder,api-generator,backend,code-generation,fastapi,pydantic
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: fastapi>=0.115
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: uvicorn>=0.30
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == 'dev'
25
+ Requires-Dist: httpx>=0.27; extra == 'dev'
26
+ Requires-Dist: mypy>=1.13; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: ruff>=0.8; extra == 'dev'
29
+ Requires-Dist: twine>=6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # API Builder
33
+
34
+ Generate a FastAPI application from decorated Python business functions.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install function-api-builder
40
+ ```
41
+
42
+ ## Use
43
+
44
+ Create `functions/example.py` in your project:
45
+
46
+ ```python
47
+ from pydantic import BaseModel
48
+ from api_builder_runtime import function
49
+
50
+
51
+ class Input(BaseModel):
52
+ value: str
53
+
54
+
55
+ class Output(BaseModel):
56
+ result: str
57
+
58
+
59
+ @function(service="example", step=1)
60
+ def run(payload: Input, deps, context) -> Output:
61
+ return Output(result=payload.value)
62
+ ```
63
+
64
+ Then run:
65
+
66
+ ```bash
67
+ api-builder
68
+ ```
69
+
70
+ The builder writes a complete `app/` FastAPI application with a public service route, optional local development routes, health checks, OpenAPI, and generated tests.
71
+
72
+ The function signature must be `(payload, deps, context)`, with Pydantic models for the payload and return value. See `examples/basic/functions/pdf_summary.py` for a multi-step service.
@@ -0,0 +1,41 @@
1
+ # API Builder
2
+
3
+ Generate a FastAPI application from decorated Python business functions.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install function-api-builder
9
+ ```
10
+
11
+ ## Use
12
+
13
+ Create `functions/example.py` in your project:
14
+
15
+ ```python
16
+ from pydantic import BaseModel
17
+ from api_builder_runtime import function
18
+
19
+
20
+ class Input(BaseModel):
21
+ value: str
22
+
23
+
24
+ class Output(BaseModel):
25
+ result: str
26
+
27
+
28
+ @function(service="example", step=1)
29
+ def run(payload: Input, deps, context) -> Output:
30
+ return Output(result=payload.value)
31
+ ```
32
+
33
+ Then run:
34
+
35
+ ```bash
36
+ api-builder
37
+ ```
38
+
39
+ The builder writes a complete `app/` FastAPI application with a public service route, optional local development routes, health checks, OpenAPI, and generated tests.
40
+
41
+ The function signature must be `(payload, deps, context)`, with Pydantic models for the payload and return value. See `examples/basic/functions/pdf_summary.py` for a multi-step service.
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from api_builder_runtime import FunctionError, function
6
+
7
+
8
+ class ExtractPdfInput(BaseModel):
9
+ file_reference: str
10
+
11
+
12
+ class ExtractPdfOutput(BaseModel):
13
+ text: str
14
+ page_count: int
15
+ source_filename: str
16
+
17
+
18
+ class GenerateSummaryInput(BaseModel):
19
+ text: str
20
+ page_count: int
21
+ source_filename: str
22
+
23
+
24
+ class GenerateSummaryOutput(BaseModel):
25
+ summary: str
26
+ key_points: list[str]
27
+
28
+
29
+ @function(service="pdf_summary", step=1, dependencies=["pdf_extractor"])
30
+ async def extract_pdf_text(
31
+ payload: ExtractPdfInput,
32
+ deps,
33
+ context,
34
+ ) -> ExtractPdfOutput:
35
+ return ExtractPdfOutput(
36
+ text="Quarterly revenue increased by twelve percent.",
37
+ page_count=1,
38
+ source_filename=payload.file_reference,
39
+ )
40
+
41
+
42
+ @function(
43
+ service="pdf_summary",
44
+ step=2,
45
+ errors={"EMPTY_DOCUMENT": 422},
46
+ )
47
+ async def generate_document_summary(
48
+ payload: GenerateSummaryInput,
49
+ deps,
50
+ context,
51
+ ) -> GenerateSummaryOutput:
52
+ if not payload.text.strip():
53
+ raise FunctionError("EMPTY_DOCUMENT", "The document contains no readable text.")
54
+ return GenerateSummaryOutput(
55
+ summary=payload.text,
56
+ key_points=["Revenue increased by twelve percent."],
57
+ )
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "function-api-builder"
7
+ version = "0.1.0"
8
+ description = "Generate complete FastAPI applications from decorated Python business functions."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+
13
+ authors = [
14
+ { name = "Sushruth Samson" }
15
+ ]
16
+
17
+ keywords = [
18
+ "fastapi",
19
+ "api-builder",
20
+ "api-generator",
21
+ "backend",
22
+ "code-generation",
23
+ "pydantic"
24
+ ]
25
+
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Environment :: Console",
29
+ "Framework :: FastAPI",
30
+ "Intended Audience :: Developers",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Operating System :: OS Independent",
33
+ "Programming Language :: Python :: 3",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Programming Language :: Python :: 3.13",
37
+ "Topic :: Software Development :: Code Generators"
38
+ ]
39
+
40
+ dependencies = [
41
+ "fastapi>=0.115",
42
+ "pydantic>=2.7",
43
+ "uvicorn>=0.30"
44
+ ]
45
+
46
+ [project.optional-dependencies]
47
+ dev = [
48
+ "build>=1.2",
49
+ "twine>=6",
50
+ "pytest>=8",
51
+ "httpx>=0.27",
52
+ "ruff>=0.8",
53
+ "mypy>=1.13"
54
+ ]
55
+
56
+ [project.scripts]
57
+ api-builder = "api_builder.cli:main"
58
+
59
+ [tool.hatch.build.targets.wheel]
60
+ packages = [
61
+ "src/api_builder",
62
+ "src/api_builder_runtime"
63
+ ]
@@ -0,0 +1,5 @@
1
+ """Generate a FastAPI app from decorated business functions."""
2
+
3
+ __all__ = ["build"]
4
+
5
+ from .generator import build
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .generator import build
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(prog="api-builder")
11
+ parser.add_argument("--project-root", type=Path, default=Path("."))
12
+ parser.add_argument("--check", action="store_true")
13
+ parser.add_argument("--force", action="store_true")
14
+ parser.add_argument("--service")
15
+ parser.add_argument("--no-dev-routes", action="store_true")
16
+ args = parser.parse_args()
17
+
18
+ result = build(
19
+ args.project_root,
20
+ service=args.service,
21
+ dev_routes=not args.no_dev_routes,
22
+ check=args.check,
23
+ force=args.force,
24
+ )
25
+ print(f"generated {len(result.files)} files")
26
+ for route in result.routes:
27
+ print(f"{route.method} {route.path} {route.operation_id}")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,868 @@
1
+ from __future__ import annotations
2
+
3
+ import compileall
4
+ import importlib
5
+ import inspect
6
+ import json
7
+ import re
8
+ import sys
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, get_type_hints
13
+
14
+ from pydantic import BaseModel
15
+
16
+ GENERATOR_VERSION = "0.3.0"
17
+ IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
18
+
19
+
20
+ class BuilderError(ValueError):
21
+ def __init__(self, code: str, message: str) -> None:
22
+ super().__init__(f"{code}: {message}")
23
+ self.code = code
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class FunctionInfo:
28
+ name: str
29
+ module: str
30
+ service: str
31
+ step: int
32
+ description: str
33
+ dependencies: tuple[str, ...]
34
+ errors: dict[str, int]
35
+ input_model: type[BaseModel]
36
+ output_model: type[BaseModel]
37
+ is_async: bool
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class RouteInfo:
42
+ method: str
43
+ path: str
44
+ operation_id: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class BuildResult:
49
+ files: tuple[Path, ...]
50
+ routes: tuple[RouteInfo, ...]
51
+
52
+
53
+ def build(
54
+ project_root: Path = Path("."),
55
+ *,
56
+ service: str | None = None,
57
+ dev_routes: bool = True,
58
+ check: bool = False,
59
+ force: bool = False,
60
+ ) -> BuildResult:
61
+ project_root = project_root.resolve()
62
+ functions = discover_functions(project_root)
63
+ if service:
64
+ functions = [info for info in functions if info.service == service]
65
+ services = _group_services(functions)
66
+ _validate_services(services)
67
+ routes = _routes(services, dev_routes)
68
+ if check:
69
+ return BuildResult(files=(), routes=routes)
70
+
71
+ app_dir = project_root / "app"
72
+ files = _generate_app(app_dir, services, dev_routes, force)
73
+ if not compileall.compile_dir(app_dir, quiet=1):
74
+ raise BuilderError("GENERATED_CODE_COMPILE_FAILED", str(app_dir))
75
+ _validate_openapi(project_root)
76
+ return BuildResult(files=tuple(files), routes=routes)
77
+
78
+
79
+ def discover_functions(project_root: Path) -> list[FunctionInfo]:
80
+ functions_dir = project_root / "functions"
81
+ if not functions_dir.exists():
82
+ raise BuilderError(
83
+ "FUNCTION_DISCOVERY_FAILED", "functions/ directory not found"
84
+ )
85
+ sys.path.insert(0, str(project_root))
86
+ importlib.invalidate_caches()
87
+ discovered: list[FunctionInfo] = []
88
+ for path in sorted(functions_dir.rglob("*.py")):
89
+ if path.name == "__init__.py":
90
+ continue
91
+ module_name = ".".join(path.relative_to(project_root).with_suffix("").parts)
92
+ module = importlib.import_module(module_name)
93
+ for _, obj in sorted(vars(module).items()):
94
+ metadata = getattr(obj, "__api_builder_function__", None)
95
+ if metadata:
96
+ discovered.append(_function_info(module_name, obj, metadata))
97
+ if not discovered:
98
+ raise BuilderError("FUNCTION_DISCOVERY_FAILED", "no @function functions found")
99
+ return discovered
100
+
101
+
102
+ def _function_info(module_name: str, fn: Any, metadata: dict[str, Any]) -> FunctionInfo:
103
+ signature = inspect.signature(fn)
104
+ params = list(signature.parameters.values())
105
+ if len(params) != 3 or [param.name for param in params] != [
106
+ "payload",
107
+ "deps",
108
+ "context",
109
+ ]:
110
+ raise BuilderError("INVALID_FUNCTION_SIGNATURE", f"{module_name}.{fn.__name__}")
111
+ hints = get_type_hints(fn)
112
+ input_model = hints.get("payload")
113
+ output_model = hints.get("return")
114
+ if not isinstance(input_model, type) or not issubclass(input_model, BaseModel):
115
+ raise BuilderError(
116
+ "FUNCTION_INPUT_MODEL_MISSING", f"{module_name}.{fn.__name__}"
117
+ )
118
+ if not isinstance(output_model, type) or not issubclass(output_model, BaseModel):
119
+ raise BuilderError(
120
+ "FUNCTION_OUTPUT_MODEL_MISSING", f"{module_name}.{fn.__name__}"
121
+ )
122
+ service = str(metadata["service"])
123
+ if not IDENT.match(service):
124
+ raise BuilderError("FUNCTION_FORMAT_INVALID", f"invalid service: {service}")
125
+ dependencies = tuple(str(dep) for dep in metadata.get("dependencies", ()))
126
+ for dep in dependencies:
127
+ if not IDENT.match(dep):
128
+ raise BuilderError(
129
+ "DEPENDENCY_NOT_REGISTERED", f"invalid dependency name: {dep}"
130
+ )
131
+ return FunctionInfo(
132
+ name=fn.__name__,
133
+ module=module_name,
134
+ service=service,
135
+ step=int(metadata["step"]),
136
+ description=str(metadata.get("description", "")),
137
+ dependencies=dependencies,
138
+ errors={
139
+ str(code): int(status)
140
+ for code, status in metadata.get("errors", {}).items()
141
+ },
142
+ input_model=input_model,
143
+ output_model=output_model,
144
+ is_async=inspect.iscoroutinefunction(fn),
145
+ )
146
+
147
+
148
+ def _group_services(functions: list[FunctionInfo]) -> dict[str, list[FunctionInfo]]:
149
+ services: dict[str, list[FunctionInfo]] = defaultdict(list)
150
+ for info in functions:
151
+ services[info.service].append(info)
152
+ return {
153
+ name: sorted(items, key=lambda item: item.step)
154
+ for name, items in sorted(services.items())
155
+ }
156
+
157
+
158
+ def _validate_services(services: dict[str, list[FunctionInfo]]) -> None:
159
+ for service, functions in services.items():
160
+ steps = [info.step for info in functions]
161
+ if len(steps) != len(set(steps)):
162
+ raise BuilderError("DUPLICATE_SERVICE_STEP", service)
163
+ expected = list(range(1, len(steps) + 1))
164
+ if steps != expected:
165
+ raise BuilderError(
166
+ "MISSING_SERVICE_STEP", f"{service}: expected {expected}, got {steps}"
167
+ )
168
+ for previous, current in zip(functions, functions[1:]):
169
+ missing = _required_fields(current.input_model) - set(
170
+ previous.output_model.model_fields
171
+ )
172
+ if missing:
173
+ raise BuilderError(
174
+ "SERVICE_SEQUENCE_INPUT_MISMATCH",
175
+ f"{service}: {previous.name} -> {current.name} missing {sorted(missing)}",
176
+ )
177
+
178
+
179
+ def _required_fields(model: type[BaseModel]) -> set[str]:
180
+ return {name for name, field in model.model_fields.items() if field.is_required()}
181
+
182
+
183
+ def _routes(
184
+ services: dict[str, list[FunctionInfo]], dev_routes: bool
185
+ ) -> tuple[RouteInfo, ...]:
186
+ routes: list[RouteInfo] = []
187
+ seen: set[tuple[str, str]] = set()
188
+ operations: set[str] = set()
189
+ for service, functions in services.items():
190
+ public = RouteInfo(
191
+ "POST", f"/api/v1/{_kebab(service)}", f"run_{service}_service"
192
+ )
193
+ _add_route(public, seen, operations)
194
+ routes.append(public)
195
+ if dev_routes:
196
+ for info in functions:
197
+ route = RouteInfo(
198
+ "POST",
199
+ f"/__dev/functions/{_kebab(service)}/{_kebab(info.name)}",
200
+ f"dev_{info.name}",
201
+ )
202
+ _add_route(route, seen, operations)
203
+ routes.append(route)
204
+ return tuple(routes)
205
+
206
+
207
+ def _add_route(
208
+ route: RouteInfo, seen: set[tuple[str, str]], operations: set[str]
209
+ ) -> None:
210
+ key = (route.method, route.path)
211
+ if key in seen:
212
+ raise BuilderError("DUPLICATE_ROUTE", f"{route.method} {route.path}")
213
+ if route.operation_id in operations:
214
+ raise BuilderError("DUPLICATE_OPERATION_ID", route.operation_id)
215
+ seen.add(key)
216
+ operations.add(route.operation_id)
217
+
218
+
219
+ def _generate_app(
220
+ app_dir: Path,
221
+ services: dict[str, list[FunctionInfo]],
222
+ dev_routes: bool,
223
+ force: bool,
224
+ ) -> list[Path]:
225
+ files: list[Path] = []
226
+ dirs = [
227
+ app_dir,
228
+ app_dir / "routers",
229
+ app_dir / "services",
230
+ app_dir / "generated",
231
+ app_dir / "tests",
232
+ ]
233
+ for directory in dirs:
234
+ directory.mkdir(parents=True, exist_ok=True)
235
+ files.append(_write_generated(directory / "__init__.py", "", force))
236
+ files.extend(
237
+ [
238
+ _write_generated(app_dir / "config.py", _config_py(), force),
239
+ _write_generated(app_dir / "context.py", _context_py(), force),
240
+ _write_generated(app_dir / "dependencies.py", _dependencies_py(), force),
241
+ _write_generated(app_dir / "errors.py", _errors_py(), force),
242
+ _write_generated(app_dir / "responses.py", _responses_py(), force),
243
+ _write_generated(app_dir / "middleware.py", _middleware_py(), force),
244
+ _write_generated(app_dir / "health.py", _health_py(), force),
245
+ _write_generated(app_dir / "main.py", _main_py(dev_routes), force),
246
+ _write_generated(
247
+ app_dir / "routers" / "public.py", _public_router_py(services), force
248
+ ),
249
+ _write_generated(
250
+ app_dir / "routers" / "dev.py",
251
+ _dev_router_py(services, dev_routes),
252
+ force,
253
+ ),
254
+ _write_generated(
255
+ app_dir / "generated" / "function_registry.py",
256
+ _function_registry_py(services),
257
+ force,
258
+ ),
259
+ _write_generated(
260
+ app_dir / "generated" / "service_registry.py",
261
+ _service_registry_py(services),
262
+ force,
263
+ ),
264
+ _write_generated(
265
+ app_dir / "generated" / "manifest.py",
266
+ _manifest_py(services, dev_routes),
267
+ force,
268
+ ),
269
+ _write_generated(
270
+ app_dir / "generated" / "manifest.json",
271
+ _manifest_json(services, dev_routes),
272
+ force,
273
+ ),
274
+ _write_generated(
275
+ app_dir / "tests" / "test_health.py", _test_health_py(), force
276
+ ),
277
+ _write_generated(
278
+ app_dir / "tests" / "test_openapi.py", _test_openapi_py(), force
279
+ ),
280
+ ]
281
+ )
282
+ for service, functions in services.items():
283
+ files.append(
284
+ _write_generated(
285
+ app_dir / "services" / f"{service}.py",
286
+ _service_py(service, functions),
287
+ force,
288
+ )
289
+ )
290
+ return files
291
+
292
+
293
+ def _config_py() -> str:
294
+ return _py(
295
+ "config.py",
296
+ """
297
+ from __future__ import annotations
298
+
299
+ from dataclasses import dataclass
300
+ from os import getenv
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class Settings:
305
+ environment: str = getenv("ENVIRONMENT", "local")
306
+ internal_dev_token: str = getenv("INTERNAL_DEV_TOKEN", "dev-token")
307
+ """,
308
+ )
309
+
310
+
311
+ def _context_py() -> str:
312
+ return _py(
313
+ "context.py",
314
+ """
315
+ from __future__ import annotations
316
+
317
+ from dataclasses import dataclass, field
318
+ from datetime import datetime
319
+ from typing import Mapping
320
+ from uuid import uuid4
321
+
322
+ from fastapi import Request
323
+
324
+
325
+ @dataclass(frozen=True, slots=True)
326
+ class ExecutionContext:
327
+ request_id: str
328
+ correlation_id: str | None = None
329
+ actor_id: str | None = None
330
+ tenant_id: str | None = None
331
+ deadline: datetime | None = None
332
+ idempotency_key: str | None = None
333
+ metadata: Mapping[str, str] = field(default_factory=dict)
334
+
335
+
336
+ def resolve_context(request: Request) -> ExecutionContext:
337
+ request_id = request.headers.get("X-Request-ID") or f"req_{uuid4().hex}"
338
+ request.state.request_id = request_id
339
+ return ExecutionContext(
340
+ request_id=request_id,
341
+ correlation_id=request.headers.get("X-Correlation-ID"),
342
+ idempotency_key=request.headers.get("Idempotency-Key"),
343
+ )
344
+ """,
345
+ )
346
+
347
+
348
+ def _dependencies_py() -> str:
349
+ return _py(
350
+ "dependencies.py",
351
+ """
352
+ from __future__ import annotations
353
+
354
+ from dataclasses import dataclass, field
355
+ from typing import Any
356
+
357
+
358
+ @dataclass(frozen=True, slots=True)
359
+ class FunctionDependencies:
360
+ function_name: str
361
+ resources: dict[str, Any]
362
+
363
+
364
+ @dataclass(frozen=True, slots=True)
365
+ class ServiceDependencies:
366
+ resources: dict[str, Any] = field(default_factory=dict)
367
+
368
+ def for_function(self, function_name: str) -> FunctionDependencies:
369
+ return FunctionDependencies(function_name=function_name, resources=self.resources)
370
+
371
+
372
+ def get_dependencies() -> ServiceDependencies:
373
+ return ServiceDependencies()
374
+ """,
375
+ )
376
+
377
+
378
+ def _errors_py() -> str:
379
+ return _py(
380
+ "errors.py",
381
+ """
382
+ from __future__ import annotations
383
+
384
+ import logging
385
+
386
+ from api_builder_runtime import FunctionError
387
+ from fastapi import FastAPI, HTTPException, Request
388
+ from fastapi.exceptions import RequestValidationError
389
+ from fastapi.responses import JSONResponse
390
+
391
+ from .responses import ErrorEnvelope
392
+
393
+ logger = logging.getLogger(__name__)
394
+
395
+
396
+ class OutputValidationError(Exception):
397
+ pass
398
+
399
+
400
+ class DevAuthenticationError(Exception):
401
+ pass
402
+
403
+
404
+ def install_exception_handlers(app: FastAPI) -> None:
405
+ @app.exception_handler(RequestValidationError)
406
+ async def request_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
407
+ return _error(request, 422, "REQUEST_VALIDATION_FAILED", "Request validation failed.")
408
+
409
+ @app.exception_handler(HTTPException)
410
+ async def http_handler(request: Request, exc: HTTPException) -> JSONResponse:
411
+ return _error(request, exc.status_code, "HTTP_ERROR", "Request failed.", headers=exc.headers)
412
+
413
+ @app.exception_handler(FunctionError)
414
+ async def function_error_handler(request: Request, exc: FunctionError) -> JSONResponse:
415
+ status = getattr(request.app.state, "error_statuses", {}).get(exc.code, 500)
416
+ return _error(request, status, exc.code, exc.message)
417
+
418
+ @app.exception_handler(OutputValidationError)
419
+ async def output_handler(request: Request, exc: OutputValidationError) -> JSONResponse:
420
+ logger.exception("generated API output validation failed")
421
+ return _error(request, 500, "OUTPUT_VALIDATION_FAILED", "Generated API output validation failed.")
422
+
423
+ @app.exception_handler(DevAuthenticationError)
424
+ async def dev_auth_handler(request: Request, exc: DevAuthenticationError) -> JSONResponse:
425
+ return _error(request, 401, "DEV_AUTHENTICATION_FAILED", "Development route authentication failed.")
426
+
427
+ @app.exception_handler(Exception)
428
+ async def unexpected_handler(request: Request, exc: Exception) -> JSONResponse:
429
+ logger.exception("unexpected API failure")
430
+ return _error(request, 500, "UNEXPECTED_RUNTIME_FAILURE", "An unexpected internal error occurred.")
431
+
432
+
433
+ def _error(
434
+ request: Request,
435
+ status_code: int,
436
+ code: str,
437
+ message: str,
438
+ *,
439
+ headers: dict[str, str] | None = None,
440
+ ) -> JSONResponse:
441
+ request_id = getattr(request.state, "request_id", "req_unknown")
442
+ body = ErrorEnvelope(error_code=code, message=message, request_id=request_id).model_dump()
443
+ return JSONResponse(status_code=status_code, content=body, headers=headers)
444
+ """,
445
+ )
446
+
447
+
448
+ def _responses_py() -> str:
449
+ return _py(
450
+ "responses.py",
451
+ """
452
+ from __future__ import annotations
453
+
454
+ from typing import Generic, TypeVar
455
+
456
+ from pydantic import BaseModel, Field
457
+
458
+ T = TypeVar("T")
459
+
460
+
461
+ class SuccessEnvelope(BaseModel, Generic[T]):
462
+ ok: bool = True
463
+ data: T
464
+ request_id: str
465
+
466
+
467
+ class ErrorItem(BaseModel):
468
+ field: str | None = None
469
+ reason: str
470
+
471
+
472
+ class ErrorEnvelope(BaseModel):
473
+ ok: bool = False
474
+ error_code: str
475
+ message: str
476
+ request_id: str
477
+ details: list[ErrorItem] = Field(default_factory=list)
478
+ """,
479
+ )
480
+
481
+
482
+ def _middleware_py() -> str:
483
+ return _py(
484
+ "middleware.py",
485
+ """
486
+ from __future__ import annotations
487
+
488
+ from uuid import uuid4
489
+
490
+ from fastapi import FastAPI, Request
491
+
492
+
493
+ def install_middleware(app: FastAPI) -> None:
494
+ @app.middleware("http")
495
+ async def request_id_middleware(request: Request, call_next):
496
+ request.state.request_id = request.headers.get("X-Request-ID") or f"req_{uuid4().hex}"
497
+ response = await call_next(request)
498
+ response.headers["X-Request-ID"] = request.state.request_id
499
+ return response
500
+ """,
501
+ )
502
+
503
+
504
+ def _health_py() -> str:
505
+ return _py(
506
+ "health.py",
507
+ """
508
+ from __future__ import annotations
509
+
510
+ from fastapi import APIRouter
511
+
512
+ router = APIRouter()
513
+
514
+
515
+ @router.get("/healthz")
516
+ async def healthz() -> dict[str, bool]:
517
+ return {"ok": True}
518
+
519
+
520
+ @router.get("/readyz")
521
+ async def readyz() -> dict[str, bool]:
522
+ return {"ok": True}
523
+ """,
524
+ )
525
+
526
+
527
+ def _main_py(dev_routes: bool) -> str:
528
+ include_dev = (
529
+ "settings.environment in {'local', 'development', 'test'}"
530
+ if dev_routes
531
+ else "False"
532
+ )
533
+ return _py(
534
+ "main.py",
535
+ f"""
536
+ from __future__ import annotations
537
+
538
+ from fastapi import FastAPI
539
+
540
+ from .config import Settings
541
+ from .errors import install_exception_handlers
542
+ from .generated.manifest import ERROR_STATUSES
543
+ from .health import router as health_router
544
+ from .middleware import install_middleware
545
+ from .routers.public import router as public_router
546
+ from .routers.dev import router as dev_router
547
+
548
+
549
+ def create_app(settings: Settings | None = None) -> FastAPI:
550
+ settings = settings or Settings()
551
+ app = FastAPI(title="Generated API", version="{GENERATOR_VERSION}")
552
+ app.state.settings = settings
553
+ app.state.error_statuses = ERROR_STATUSES
554
+ install_middleware(app)
555
+ install_exception_handlers(app)
556
+ app.include_router(health_router)
557
+ app.include_router(public_router)
558
+ if {include_dev}:
559
+ app.include_router(dev_router)
560
+ return app
561
+
562
+
563
+ app = create_app()
564
+ """,
565
+ )
566
+
567
+
568
+ def _public_router_py(services: dict[str, list[FunctionInfo]]) -> str:
569
+ lines = [
570
+ _header("routers/public.py"),
571
+ "from __future__ import annotations",
572
+ "",
573
+ "from fastapi import APIRouter, Depends",
574
+ "",
575
+ "from app.context import ExecutionContext, resolve_context",
576
+ "from app.dependencies import ServiceDependencies, get_dependencies",
577
+ "from app.responses import SuccessEnvelope",
578
+ "",
579
+ ]
580
+ for service, functions in services.items():
581
+ first = functions[0]
582
+ last = functions[-1]
583
+ lines.extend(
584
+ [
585
+ f"from app.services.{service} import run_{service}_service",
586
+ f"from {first.module} import {first.input_model.__name__}",
587
+ f"from {last.module} import {last.output_model.__name__}",
588
+ ]
589
+ )
590
+ lines.extend(["", "router = APIRouter()", ""])
591
+ for service, functions in services.items():
592
+ first = functions[0]
593
+ last = functions[-1]
594
+ lines.extend(
595
+ [
596
+ "@router.post(",
597
+ f' "/api/v1/{_kebab(service)}",',
598
+ f' operation_id="run_{service}_service",',
599
+ f" response_model=SuccessEnvelope[{last.output_model.__name__}],",
600
+ ")",
601
+ f"async def run_{service}_route(",
602
+ f" payload: {first.input_model.__name__},",
603
+ " deps: ServiceDependencies = Depends(get_dependencies),",
604
+ " context: ExecutionContext = Depends(resolve_context),",
605
+ f") -> SuccessEnvelope[{last.output_model.__name__}]:",
606
+ f" result = await run_{service}_service(payload, deps, context)",
607
+ " return SuccessEnvelope(data=result, request_id=context.request_id)",
608
+ "",
609
+ ]
610
+ )
611
+ return "\n".join(lines)
612
+
613
+
614
+ def _dev_router_py(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
615
+ lines = [
616
+ _header("routers/dev.py"),
617
+ "from __future__ import annotations",
618
+ "",
619
+ "from fastapi import APIRouter, Depends, Header, Request",
620
+ "from pydantic import ValidationError",
621
+ "",
622
+ "from app.context import ExecutionContext, resolve_context",
623
+ "from app.dependencies import ServiceDependencies, get_dependencies",
624
+ "from app.errors import DevAuthenticationError, OutputValidationError",
625
+ "from app.responses import SuccessEnvelope",
626
+ "",
627
+ ]
628
+ if not dev_routes:
629
+ lines.extend(["router = APIRouter()", ""])
630
+ return "\n".join(lines)
631
+ for functions in services.values():
632
+ for info in functions:
633
+ lines.extend(
634
+ [
635
+ f"from {info.module} import {info.name}, {info.input_model.__name__}, {info.output_model.__name__}",
636
+ ]
637
+ )
638
+ lines.extend(
639
+ [
640
+ "",
641
+ "router = APIRouter()",
642
+ "",
643
+ 'def require_dev_token(request: Request, x_internal_dev_token: str | None = Header(default=None, alias="X-Internal-Dev-Token")) -> None:',
644
+ " if x_internal_dev_token != request.app.state.settings.internal_dev_token:",
645
+ " raise DevAuthenticationError()",
646
+ "",
647
+ ]
648
+ )
649
+ for service, functions in services.items():
650
+ for info in functions:
651
+ call = (
652
+ f"await {info.name}(payload, deps.for_function({info.name!r}), context)"
653
+ if info.is_async
654
+ else f"{info.name}(payload, deps.for_function({info.name!r}), context)"
655
+ )
656
+ lines.extend(
657
+ [
658
+ "@router.post(",
659
+ f' "/__dev/functions/{_kebab(service)}/{_kebab(info.name)}",',
660
+ f' operation_id="dev_{info.name}",',
661
+ f" response_model=SuccessEnvelope[{info.output_model.__name__}],",
662
+ ")",
663
+ f"async def dev_{info.name}_route(",
664
+ f" payload: {info.input_model.__name__},",
665
+ " _auth: None = Depends(require_dev_token),",
666
+ " deps: ServiceDependencies = Depends(get_dependencies),",
667
+ " context: ExecutionContext = Depends(resolve_context),",
668
+ f") -> SuccessEnvelope[{info.output_model.__name__}]:",
669
+ f" raw = {call}",
670
+ " try:",
671
+ f" result = {info.output_model.__name__}.model_validate(raw)",
672
+ " except ValidationError as exc:",
673
+ " raise OutputValidationError() from exc",
674
+ " return SuccessEnvelope(data=result, request_id=context.request_id)",
675
+ "",
676
+ ]
677
+ )
678
+ return "\n".join(lines)
679
+
680
+
681
+ def _service_py(service: str, functions: list[FunctionInfo]) -> str:
682
+ lines = [
683
+ _header(f"services/{service}.py"),
684
+ "from __future__ import annotations",
685
+ "",
686
+ "from pydantic import ValidationError",
687
+ "",
688
+ "from app.context import ExecutionContext",
689
+ "from app.dependencies import ServiceDependencies",
690
+ "from app.errors import OutputValidationError",
691
+ "",
692
+ ]
693
+ for info in functions:
694
+ lines.append(
695
+ f"from {info.module} import {info.name}, {info.input_model.__name__}, {info.output_model.__name__}"
696
+ )
697
+ first = functions[0]
698
+ last = functions[-1]
699
+ lines.extend(
700
+ [
701
+ "",
702
+ f"async def run_{service}_service(",
703
+ f" payload: {first.input_model.__name__},",
704
+ " deps: ServiceDependencies,",
705
+ " context: ExecutionContext,",
706
+ f") -> {last.output_model.__name__}:",
707
+ " current = payload",
708
+ ]
709
+ )
710
+ for index, info in enumerate(functions, start=1):
711
+ if index > 1:
712
+ lines.extend(
713
+ [
714
+ " try:",
715
+ f" current = {info.input_model.__name__}.model_validate(current.model_dump())",
716
+ " except ValidationError as exc:",
717
+ " raise OutputValidationError() from exc",
718
+ ]
719
+ )
720
+ call = (
721
+ f"await {info.name}(current, deps.for_function({info.name!r}), context)"
722
+ if info.is_async
723
+ else f"{info.name}(current, deps.for_function({info.name!r}), context)"
724
+ )
725
+ lines.extend(
726
+ [
727
+ f" raw_{index} = {call}",
728
+ " try:",
729
+ f" current = {info.output_model.__name__}.model_validate(raw_{index})",
730
+ " except ValidationError as exc:",
731
+ " raise OutputValidationError() from exc",
732
+ ]
733
+ )
734
+ lines.extend([" return current", ""])
735
+ return "\n".join(lines)
736
+
737
+
738
+ def _function_registry_py(services: dict[str, list[FunctionInfo]]) -> str:
739
+ lines = [_header("generated/function_registry.py"), "FUNCTIONS = ("]
740
+ for functions in services.values():
741
+ for info in functions:
742
+ lines.append(
743
+ f" {{'service': {info.service!r}, 'step': {info.step}, 'name': {info.name!r}, 'module': {info.module!r}}},"
744
+ )
745
+ lines.extend([")", ""])
746
+ return "\n".join(lines)
747
+
748
+
749
+ def _service_registry_py(services: dict[str, list[FunctionInfo]]) -> str:
750
+ lines = [_header("generated/service_registry.py"), "SERVICES = {"]
751
+ for service, functions in services.items():
752
+ lines.append(f" {service!r}: {[info.name for info in functions]!r},")
753
+ lines.extend(["}", ""])
754
+ return "\n".join(lines)
755
+
756
+
757
+ def _manifest(
758
+ services: dict[str, list[FunctionInfo]], dev_routes: bool
759
+ ) -> dict[str, Any]:
760
+ return {
761
+ "generated_file": "DO NOT EDIT MANUALLY",
762
+ "generator_version": GENERATOR_VERSION,
763
+ "dev_routes": dev_routes,
764
+ "services": {
765
+ service: [
766
+ {
767
+ "name": info.name,
768
+ "module": info.module,
769
+ "step": info.step,
770
+ "dependencies": list(info.dependencies),
771
+ "errors": info.errors,
772
+ }
773
+ for info in functions
774
+ ]
775
+ for service, functions in services.items()
776
+ },
777
+ }
778
+
779
+
780
+ def _manifest_py(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
781
+ error_statuses = {
782
+ code: status
783
+ for functions in services.values()
784
+ for info in functions
785
+ for code, status in info.errors.items()
786
+ }
787
+ return _py(
788
+ "generated/manifest.py",
789
+ f"MANIFEST = {_manifest(services, dev_routes)!r}\nERROR_STATUSES = {error_statuses!r}\n",
790
+ )
791
+
792
+
793
+ def _manifest_json(services: dict[str, list[FunctionInfo]], dev_routes: bool) -> str:
794
+ return json.dumps(_manifest(services, dev_routes), indent=2, sort_keys=True) + "\n"
795
+
796
+
797
+ def _test_health_py() -> str:
798
+ return _py(
799
+ "tests/test_health.py",
800
+ """
801
+ from app.main import create_app
802
+
803
+
804
+ def test_health_routes_exist():
805
+ app = create_app()
806
+ paths = set(app.openapi()["paths"])
807
+ assert "/healthz" in paths
808
+ assert "/readyz" in paths
809
+ """,
810
+ )
811
+
812
+
813
+ def _test_openapi_py() -> str:
814
+ return _py(
815
+ "tests/test_openapi.py",
816
+ """
817
+ from app.main import create_app
818
+
819
+
820
+ def test_openapi_generates():
821
+ schema = create_app().openapi()
822
+ assert "paths" in schema
823
+ """,
824
+ )
825
+
826
+
827
+ def _validate_openapi(project_root: Path) -> None:
828
+ sys.path.insert(0, str(project_root))
829
+ importlib.invalidate_caches()
830
+ for name in list(sys.modules):
831
+ if name == "app" or name.startswith("app."):
832
+ del sys.modules[name]
833
+ try:
834
+ module = importlib.import_module("app.main")
835
+ module.create_app().openapi()
836
+ except Exception as exc:
837
+ raise BuilderError("GENERATED_OPENAPI_INVALID", str(exc)) from exc
838
+
839
+
840
+ def _write_generated(path: Path, text: str, force: bool) -> Path:
841
+ if path.exists() and not force:
842
+ existing = path.read_text()
843
+ if (
844
+ existing
845
+ and "GENERATED FILE" not in existing
846
+ and "generated_file" not in existing
847
+ ):
848
+ raise BuilderError("GENERATED_FILE_CONFLICT", str(path))
849
+ tmp = path.with_suffix(path.suffix + ".tmp")
850
+ tmp.write_text(text)
851
+ tmp.replace(path)
852
+ return path
853
+
854
+
855
+ def _py(name: str, body: str) -> str:
856
+ return (
857
+ _header(name)
858
+ + "\n".join(line.rstrip() for line in body.strip().splitlines())
859
+ + "\n"
860
+ )
861
+
862
+
863
+ def _header(name: str) -> str:
864
+ return f"# GENERATED FILE - DO NOT EDIT MANUALLY\n# Source: functions/**/*.py\n# Generated: {name}\n# Generator version: {GENERATOR_VERSION}\n\n"
865
+
866
+
867
+ def _kebab(value: str) -> str:
868
+ return value.replace("_", "-")
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ErrorDetail:
9
+ field: str | None
10
+ reason: str
11
+ metadata: dict[str, Any] | None = None
12
+
13
+
14
+ class FunctionError(Exception):
15
+ def __init__(
16
+ self,
17
+ code: str,
18
+ message: str,
19
+ *,
20
+ details: list[ErrorDetail] | None = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.code = code
24
+ self.message = message
25
+ self.details = details or []
26
+
27
+
28
+ def function(
29
+ *,
30
+ service: str,
31
+ step: int,
32
+ description: str = "",
33
+ dependencies: list[str] | tuple[str, ...] = (),
34
+ errors: dict[str, int] | None = None,
35
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
36
+ def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
37
+ setattr(
38
+ fn,
39
+ "__api_builder_function__",
40
+ {
41
+ "service": service,
42
+ "step": step,
43
+ "description": description,
44
+ "dependencies": tuple(dependencies),
45
+ "errors": errors or {},
46
+ },
47
+ )
48
+ return fn
49
+
50
+ return decorate
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from textwrap import dedent
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from api_builder import build
14
+ from api_builder.generator import BuilderError, discover_functions
15
+
16
+
17
+ def write_project(root: Path) -> None:
18
+ functions = root / "functions"
19
+ functions.mkdir()
20
+ (functions / "__init__.py").write_text("")
21
+ (functions / "pdf_summary.py").write_text(
22
+ dedent(
23
+ """
24
+ from pydantic import BaseModel
25
+ from api_builder_runtime import FunctionError, function
26
+
27
+
28
+ class ExtractPdfInput(BaseModel):
29
+ file_reference: str
30
+
31
+
32
+ class ExtractPdfOutput(BaseModel):
33
+ text: str
34
+ page_count: int
35
+ source_filename: str
36
+
37
+
38
+ class GenerateSummaryInput(BaseModel):
39
+ text: str
40
+ page_count: int
41
+ source_filename: str
42
+
43
+
44
+ class GenerateSummaryOutput(BaseModel):
45
+ summary: str
46
+ key_points: list[str]
47
+
48
+
49
+ @function(service="pdf_summary", step=1, dependencies=["pdf_extractor"])
50
+ async def extract_pdf_text(payload: ExtractPdfInput, deps, context) -> ExtractPdfOutput:
51
+ return ExtractPdfOutput(
52
+ text="Quarterly revenue increased by twelve percent.",
53
+ page_count=1,
54
+ source_filename=payload.file_reference,
55
+ )
56
+
57
+
58
+ @function(
59
+ service="pdf_summary",
60
+ step=2,
61
+ errors={"EMPTY_DOCUMENT": 422},
62
+ )
63
+ async def generate_document_summary(
64
+ payload: GenerateSummaryInput,
65
+ deps,
66
+ context,
67
+ ) -> GenerateSummaryOutput:
68
+ if not payload.text.strip():
69
+ raise FunctionError("EMPTY_DOCUMENT", "The document contains no readable text.")
70
+ return GenerateSummaryOutput(
71
+ summary=payload.text,
72
+ key_points=["Revenue increased by twelve percent."],
73
+ )
74
+ """
75
+ )
76
+ )
77
+
78
+
79
+ def clear_modules() -> None:
80
+ for name in list(sys.modules):
81
+ if (
82
+ name == "functions"
83
+ or name.startswith("functions.")
84
+ or name == "app"
85
+ or name.startswith("app.")
86
+ ):
87
+ del sys.modules[name]
88
+ importlib.invalidate_caches()
89
+
90
+
91
+ def test_discovers_and_orders_functions(tmp_path: Path) -> None:
92
+ write_project(tmp_path)
93
+ clear_modules()
94
+ found = discover_functions(tmp_path)
95
+ assert [item.name for item in found] == [
96
+ "extract_pdf_text",
97
+ "generate_document_summary",
98
+ ]
99
+ assert [item.step for item in found] == [1, 2]
100
+
101
+
102
+ def test_generation_creates_runnable_app_and_routes(tmp_path: Path) -> None:
103
+ write_project(tmp_path)
104
+ clear_modules()
105
+ result = build(tmp_path)
106
+
107
+ assert (tmp_path / "app/main.py").exists()
108
+ assert (tmp_path / "app/services/pdf_summary.py").exists()
109
+ assert (tmp_path / "app/generated/manifest.json").exists()
110
+ assert ("POST", "/api/v1/pdf-summary") in {
111
+ (route.method, route.path) for route in result.routes
112
+ }
113
+ assert ("POST", "/__dev/functions/pdf-summary/extract-pdf-text") in {
114
+ (route.method, route.path) for route in result.routes
115
+ }
116
+
117
+ manifest = json.loads((tmp_path / "app/generated/manifest.json").read_text())
118
+ assert manifest["services"]["pdf_summary"][0]["name"] == "extract_pdf_text"
119
+
120
+
121
+ def test_http_public_dev_health_and_openapi(tmp_path: Path) -> None:
122
+ write_project(tmp_path)
123
+ clear_modules()
124
+ build(tmp_path)
125
+ sys.path.insert(0, str(tmp_path))
126
+ clear_modules()
127
+ main = importlib.import_module("app.main")
128
+ config = importlib.import_module("app.config")
129
+ app = main.create_app(
130
+ config.Settings(environment="test", internal_dev_token="secret")
131
+ )
132
+
133
+ async def run() -> None:
134
+ transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
135
+ async with httpx.AsyncClient(
136
+ transport=transport, base_url="http://test"
137
+ ) as client:
138
+ health = await client.get("/healthz")
139
+ assert health.status_code == 200
140
+
141
+ public = await client.post(
142
+ "/api/v1/pdf-summary", json={"file_reference": "q3.pdf"}
143
+ )
144
+ assert public.status_code == 200
145
+ assert public.json()["data"]["summary"].startswith("Quarterly")
146
+
147
+ missing_token = await client.post(
148
+ "/__dev/functions/pdf-summary/extract-pdf-text",
149
+ json={"file_reference": "q3.pdf"},
150
+ )
151
+ assert missing_token.status_code == 401
152
+
153
+ dev = await client.post(
154
+ "/__dev/functions/pdf-summary/extract-pdf-text",
155
+ json={"file_reference": "q3.pdf"},
156
+ headers={"X-Internal-Dev-Token": "secret"},
157
+ )
158
+ assert dev.status_code == 200
159
+ assert dev.json()["data"]["page_count"] == 1
160
+
161
+ schema = app.openapi()
162
+ assert "/api/v1/pdf-summary" in schema["paths"]
163
+ assert "/__dev/functions/pdf-summary/extract-pdf-text" in schema["paths"]
164
+ prod = main.create_app(
165
+ config.Settings(environment="production", internal_dev_token="secret")
166
+ )
167
+ assert (
168
+ "/__dev/functions/pdf-summary/extract-pdf-text"
169
+ not in prod.openapi()["paths"]
170
+ )
171
+
172
+ asyncio.run(run())
173
+
174
+
175
+ def test_sequence_mismatch_fails(tmp_path: Path) -> None:
176
+ write_project(tmp_path)
177
+ path = tmp_path / "functions/pdf_summary.py"
178
+ path.write_text(
179
+ path.read_text().replace("source_filename: str", "language: str", 1)
180
+ )
181
+ clear_modules()
182
+ with pytest.raises(BuilderError, match="SERVICE_SEQUENCE_INPUT_MISMATCH"):
183
+ build(tmp_path, check=True)
184
+
185
+
186
+ def test_idempotent_regeneration(tmp_path: Path) -> None:
187
+ write_project(tmp_path)
188
+ clear_modules()
189
+ build(tmp_path)
190
+ before = {
191
+ path.relative_to(tmp_path): path.read_text()
192
+ for path in (tmp_path / "app").rglob("*")
193
+ if path.is_file() and "__pycache__" not in path.parts
194
+ }
195
+ build(tmp_path)
196
+ after = {
197
+ path.relative_to(tmp_path): path.read_text()
198
+ for path in (tmp_path / "app").rglob("*")
199
+ if path.is_file() and "__pycache__" not in path.parts
200
+ }
201
+ assert before == after