diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index a2e5c4e5..350a7115 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -106,6 +106,7 @@ dev = [ "langchain>=1.0.0", "langchain-core>=1.0.0", "pyright>=1.1.411", + "pytest-benchmark>=5.2.3", ] @@ -135,6 +136,10 @@ pythonVersion = "3.12" [tool.pytest.ini_options] asyncio_mode = "auto" norecursedirs = [".venv", "dist", "build", "__pycache__"] +markers = [ + "benchmark: performance benchmark, excluded by default (run with -m benchmark)", +] +addopts = "-m 'not benchmark'" [tool.coverage.run] source = ["src/fastapi_startkit"] diff --git a/fastapi_startkit/tests/fastapi/benchmarks/__init__.py b/fastapi_startkit/tests/fastapi/benchmarks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py b/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py new file mode 100644 index 00000000..93ded9f6 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py @@ -0,0 +1,36 @@ +"""Benchmarks for Application boot and provider register/boot (task #1045). + +Deselected by default (see `addopts` in pyproject.toml); run explicitly with: + + uv run pytest -m benchmark tests/fastapi/benchmarks/test_application_boot_benchmark.py +""" + +import pytest +from fastapi import FastAPI + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.fastapi.providers.fastapi_provider import FastAPIProvider + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + """Application() replaces the global Container singleton; restore it after each round.""" + original = Container._instance + yield + Container._instance = original + + +@pytest.mark.benchmark(group="application-boot") +class TestApplicationBootBenchmarks: + def test_boot_default_providers(self, benchmark, tmp_path): + """Full Application() init: environment resolution, config, default providers register+boot.""" + benchmark(Application, base_path=tmp_path, env="testing") + + def test_boot_with_fastapi_provider(self, benchmark, tmp_path): + """Same as above, plus FastAPIProvider register+boot (FastAPI instance, exception handlers, command).""" + benchmark(Application, base_path=tmp_path, env="testing", providers=[FastAPIProvider]) + + def test_boot_raw_fastapi_baseline(self, benchmark): + """Raw FastAPI() instantiation — the floor that Application's boot overhead is measured against.""" + benchmark(FastAPI) diff --git a/fastapi_startkit/tests/fastapi/benchmarks/test_request_handling_benchmark.py b/fastapi_startkit/tests/fastapi/benchmarks/test_request_handling_benchmark.py new file mode 100644 index 00000000..a5dcf0c6 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/benchmarks/test_request_handling_benchmark.py @@ -0,0 +1,68 @@ +"""Benchmarks for request handling through the FastAPI integration layer (task #1045). + +Deselected by default (see `addopts` in pyproject.toml); run explicitly with: + + uv run pytest -m benchmark tests/fastapi/benchmarks/test_request_handling_benchmark.py +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fastapi_startkit.fastapi.routers.router import Router + + +def ping(): + return {"ok": True} + + +def show_post(post: int): + return {"id": post} + + +class PostController: + def show(self, post): + return show_post(post) + + +@pytest.fixture(scope="module") +def router_based_client(): + """A trivial GET endpoint registered through the Router wrapper.""" + router = Router() + router.get("/ping", ping) + app = FastAPI() + app.include_router(router.router) + with TestClient(app) as client: + yield client + + +@pytest.fixture(scope="module") +def raw_fastapi_client(): + """The same endpoint registered directly on FastAPI, bypassing the Router wrapper.""" + app = FastAPI() + app.get("/ping")(ping) + with TestClient(app) as client: + yield client + + +@pytest.fixture(scope="module") +def resource_client(): + """A resource()-expanded route with a path parameter, exercising Starlette's path matching.""" + router = Router() + router.resource("posts", PostController) + app = FastAPI() + app.include_router(router.router) + with TestClient(app) as client: + yield client + + +@pytest.mark.benchmark(group="request-handling-json-get") +class TestRequestHandlingBenchmarks: + def test_router_based_request(self, benchmark, router_based_client): + benchmark(router_based_client.get, "/ping") + + def test_raw_fastapi_request_baseline(self, benchmark, raw_fastapi_client): + benchmark(raw_fastapi_client.get, "/ping") + + def test_resource_route_with_path_param(self, benchmark, resource_client): + benchmark(resource_client.get, "/posts/1") diff --git a/fastapi_startkit/tests/fastapi/benchmarks/test_router_benchmark.py b/fastapi_startkit/tests/fastapi/benchmarks/test_router_benchmark.py new file mode 100644 index 00000000..0761a332 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/benchmarks/test_router_benchmark.py @@ -0,0 +1,71 @@ +"""Benchmarks for Router route registration and resource() CRUD expansion (task #1045). + +Deselected by default (see `addopts` in pyproject.toml); run explicitly with: + + uv run pytest -m benchmark tests/fastapi/benchmarks/test_router_benchmark.py +""" + +import pytest +from fastapi import APIRouter + +from fastapi_startkit.fastapi.routers.router import Router + + +def endpoint(): + return {"ok": True} + + +class PostController: + def index(self): + return [] + + def create(self): + return {} + + def store(self): + return {} + + def show(self, post): + return {"id": post} + + def edit(self, post): + return {"id": post} + + def update(self, post): + return {"id": post} + + def destroy(self, post): + return {"id": post} + + +@pytest.mark.benchmark(group="router-single-route") +class TestSingleRouteRegistrationBenchmarks: + def test_router_get(self, benchmark): + benchmark(lambda: Router().get("/items", endpoint)) + + def test_raw_apirouter_get_baseline(self, benchmark): + """Registering the same route directly on APIRouter, bypassing the Router wrapper.""" + benchmark(lambda: APIRouter().add_api_route("/items", endpoint, methods=["GET"])) + + +@pytest.mark.benchmark(group="router-resource-expansion") +class TestResourceExpansionBenchmarks: + def test_resource_full_crud(self, benchmark): + """Router.resource() expanding a controller into all 7 CRUD routes.""" + benchmark(lambda: Router().resource("posts", PostController)) + + def test_raw_apirouter_equivalent_crud_baseline(self, benchmark): + """Hand-registering the same 7 routes directly — the floor resource() is measured against.""" + + def register(): + router = APIRouter() + controller = PostController() + router.add_api_route("/posts", controller.index, methods=["GET"], name="posts") + router.add_api_route("/posts/create", controller.create, methods=["GET"], name="posts.create") + router.add_api_route("/posts", controller.store, methods=["POST"], name="posts.store") + router.add_api_route("/posts/{post}", controller.show, methods=["GET"], name="posts.show") + router.add_api_route("/posts/{post}/edit", controller.edit, methods=["GET"], name="posts.edit") + router.add_api_route("/posts/{post}", controller.update, methods=["PUT"], name="posts.update") + router.add_api_route("/posts/{post}", controller.destroy, methods=["DELETE"], name="posts.destroy") + + benchmark(register) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 5808c35b..f328afe6 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -583,6 +583,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "ruff" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -627,6 +628,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.411" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, @@ -1373,6 +1375,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1537,6 +1548,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"