diff --git a/.gitignore b/.gitignore index ee65e917..5a93aab3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ **/.env **/*.sqlite **/*.vite +**/.coverage +**/.coverage.* +**/htmlcov/ +**/coverage.xml fastapi_startkit/dist fastapi_startkit.github.io.git laravel-repo diff --git a/example/vite-app/app/__init__.py b/example/vite-app/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/app/providers/__init__.py b/example/vite-app/app/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/providers/fastapi_provider.py b/example/vite-app/app/providers/fastapi_provider.py similarity index 57% rename from example/vite-app/providers/fastapi_provider.py rename to example/vite-app/app/providers/fastapi_provider.py index 2ae5d739..e43b4331 100644 --- a/example/vite-app/providers/fastapi_provider.py +++ b/example/vite-app/app/providers/fastapi_provider.py @@ -1,7 +1,4 @@ -from pathlib import Path - from fastapi import FastAPI -from starlette.templating import Jinja2Templates from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider @@ -14,11 +11,6 @@ def register(self) -> None: ) self.app.use_fastapi(fastapi) - # Bind Jinja2Templates so ViteProvider can inject vite() globals into it. - templates_dir = Path(self.app.base_path) / "templates" - templates = Jinja2Templates(directory=str(templates_dir)) - self.app.bind("templates", templates) - def boot(self) -> None: super().boot() diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 9dc64939..4cde13a8 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -4,8 +4,7 @@ from fastapi_startkit.logging import LogProvider from fastapi_startkit.vite import ViteProvider -# from config.vite import ViteConfig -from providers.fastapi_provider import FastAPIProvider +from app.providers.fastapi_provider import FastAPIProvider app: Application = Application( base_path=Path(__file__).resolve().parent.parent, diff --git a/example/vite-app/pyproject.toml b/example/vite-app/pyproject.toml index d11352a4..1ea54589 100644 --- a/example/vite-app/pyproject.toml +++ b/example/vite-app/pyproject.toml @@ -14,4 +14,7 @@ fastapi-startkit = { path = "../../fastapi_startkit", editable = true } [dependency-groups] dev = [ "dumpdie>=1.5.0", + "httpx>=0.28.1", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", ] diff --git a/example/vite-app/pytest.ini b/example/vite-app/pytest.ini new file mode 100644 index 00000000..82bc8d15 --- /dev/null +++ b/example/vite-app/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +pythonpath = . diff --git a/example/vite-app/templates/index.html b/example/vite-app/resources/templates/index.html similarity index 100% rename from example/vite-app/templates/index.html rename to example/vite-app/resources/templates/index.html diff --git a/example/vite-app/routes/web.py b/example/vite-app/routes/web.py index 78a3f090..063e1854 100644 --- a/example/vite-app/routes/web.py +++ b/example/vite-app/routes/web.py @@ -1,14 +1,14 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse +from fastapi_startkit.application import app + web = APIRouter() @web.get("/", response_class=HTMLResponse) async def index(request: Request): - from bootstrap.application import app - - templates = app.make("templates") + templates = app().make("templates") return templates.TemplateResponse(request, "index.html") diff --git a/example/vite-app/tests/__init__.py b/example/vite-app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/tests/test_case.py b/example/vite-app/tests/test_case.py new file mode 100644 index 00000000..c3319011 --- /dev/null +++ b/example/vite-app/tests/test_case.py @@ -0,0 +1,14 @@ +from abc import ABC +from typing import TYPE_CHECKING + +from fastapi_startkit.testing import TestCase as BaseTestCase + +if TYPE_CHECKING: + from fastapi_startkit.application import Application + + +class TestCase(BaseTestCase, ABC): + def get_application(self) -> "Application": + from bootstrap.application import app + + return app diff --git a/example/vite-app/tests/test_web_routes.py b/example/vite-app/tests/test_web_routes.py new file mode 100644 index 00000000..521c7412 --- /dev/null +++ b/example/vite-app/tests/test_web_routes.py @@ -0,0 +1,18 @@ +from fastapi_startkit.fastapi.testing import HttpTestCase + +from tests.test_case import TestCase + + +class TestHomeController(TestCase, HttpTestCase): + async def test_health_endpoint_returns_ok(self): + response = await self.get("/api/health") + + response.assert_ok() + assert response.json() == {"status": "healthy"} + + async def test_index_page_renders(self): + response = await self.get("/") + + response.assert_ok() + body = response.text + assert "FastAPI StartKit" in body diff --git a/example/vite-app/uv.lock b/example/vite-app/uv.lock index 47c56698..52df7eb7 100644 --- a/example/vite-app/uv.lock +++ b/example/vite-app/uv.lock @@ -280,7 +280,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.26.0" +version = "0.45.0" source = { editable = "../../fastapi_startkit" } dependencies = [ { name = "cleo" }, @@ -305,23 +305,26 @@ vite = [ requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, + { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "dotty-dict", specifier = ">=1.3.1" }, { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, + { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, + { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ @@ -329,10 +332,12 @@ dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "twine", specifier = ">=6.2.0" }, @@ -494,6 +499,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -599,6 +613,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pendulum" version = "3.2.0" @@ -642,6 +665,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "pydantic" version = "2.13.3" @@ -746,6 +778,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1161,6 +1222,9 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "dumpdie" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -1170,7 +1234,12 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "dumpdie", specifier = ">=1.5.0" }] +dev = [ + { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] [[package]] name = "watchfiles" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index c6c8cf31..e4d4356f 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -1,9 +1,11 @@ from .vite import Vite +from .config.vite import ViteConfig from .providers.provider import ViteProvider from .exceptions import ViteException, ViteManifestNotFoundException __all__ = [ "Vite", + "ViteConfig", "ViteProvider", "ViteException", "ViteManifestNotFoundException", diff --git a/fastapi_startkit/tests/vite/test_vite.py b/fastapi_startkit/tests/vite/test_vite.py deleted file mode 100644 index b7cb6a11..00000000 --- a/fastapi_startkit/tests/vite/test_vite.py +++ /dev/null @@ -1,300 +0,0 @@ -import json - -import pytest - -from fastapi_startkit.vite.exceptions import ViteException, ViteManifestNotFoundException -from fastapi_startkit.vite.vite import Vite - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_vite(tmp_path, build_dir="build", manifest=None, hot_content=None, asset_url=""): - """Create a Vite instance with an optional manifest.json and/or hot file.""" - public = tmp_path / "public" - public.mkdir() - - if manifest is not None: - build = public / build_dir - build.mkdir(parents=True) - (build / "manifest.json").write_text(json.dumps(manifest)) - - if hot_content is not None: - (public / "hot").write_text(hot_content) - - # Clear class-level manifest cache between tests - Vite._manifests.clear() - - return Vite(public_path=str(public), build_directory=build_dir, asset_url=asset_url) - - -SIMPLE_MANIFEST = { - "resources/js/app.js": { - "file": "assets/app-abc123.js", - "src": "resources/js/app.js", - "isEntry": True, - } -} - -MANIFEST_WITH_CSS = { - "resources/js/app.js": { - "file": "assets/app-abc123.js", - "src": "resources/js/app.js", - "isEntry": True, - "css": ["assets/app-def456.css"], - } -} - - -# --------------------------------------------------------------------------- -# Production mode (no hot file) -# --------------------------------------------------------------------------- - - -class TestProductionMode: - def test_is_not_running_hot_without_hot_file(self, tmp_path): - vite = make_vite(tmp_path, manifest=SIMPLE_MANIFEST) - assert vite.is_running_hot() is False - - def test_asset_url_contains_hashed_filename(self, tmp_path): - vite = make_vite(tmp_path, manifest=SIMPLE_MANIFEST) - result = vite("resources/js/app.js") - assert "app-abc123.js" in result - - def test_script_tag_generated(self, tmp_path): - vite = make_vite(tmp_path, manifest=SIMPLE_MANIFEST) - result = vite("resources/js/app.js") - assert "