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/database-app/app/models/category.py b/example/database-app/app/models/category.py index 29655e70..76af5966 100644 --- a/example/database-app/app/models/category.py +++ b/example/database-app/app/models/category.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import HasMany, HasManyThrough +from fastapi_startkit.masoniteorm import HasMany, HasManyThrough if TYPE_CHECKING: from app.models.course import Course diff --git a/example/database-app/app/models/course.py b/example/database-app/app/models/course.py index 7b31a9a6..9f030a09 100644 --- a/example/database-app/app/models/course.py +++ b/example/database-app/app/models/course.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo, HasMany, BelongsToMany, MorphMany +from fastapi_startkit.masoniteorm import BelongsTo, HasMany, BelongsToMany, MorphMany if TYPE_CHECKING: from app.models.category import Category diff --git a/example/database-app/app/models/lesson.py b/example/database-app/app/models/lesson.py index c5540bf2..e507d16e 100644 --- a/example/database-app/app/models/lesson.py +++ b/example/database-app/app/models/lesson.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo, MorphMany +from fastapi_startkit.masoniteorm import BelongsTo, MorphMany if TYPE_CHECKING: from app.models.course import Course diff --git a/example/database-app/app/models/profile.py b/example/database-app/app/models/profile.py index 5a6088fe..11fa8a7a 100644 --- a/example/database-app/app/models/profile.py +++ b/example/database-app/app/models/profile.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo +from fastapi_startkit.masoniteorm import BelongsTo if TYPE_CHECKING: from app.models.user import User diff --git a/example/database-app/app/models/review.py b/example/database-app/app/models/review.py index f36f2738..2d19021f 100644 --- a/example/database-app/app/models/review.py +++ b/example/database-app/app/models/review.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import MorphTo +from fastapi_startkit.masoniteorm import MorphTo class Review(Model): __table__ = "reviews" diff --git a/example/database-app/app/models/user.py b/example/database-app/app/models/user.py index 81107cee..c31152c7 100644 --- a/example/database-app/app/models/user.py +++ b/example/database-app/app/models/user.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import HasMany, HasOne, BelongsToMany +from fastapi_startkit.masoniteorm import HasMany, HasOne, BelongsToMany if TYPE_CHECKING: from app.models.profile import Profile 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/config/vite.py b/example/vite-app/config/vite.py index 71431700..80102997 100644 --- a/example/vite-app/config/vite.py +++ b/example/vite-app/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" 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/pyproject.toml b/fastapi_startkit/pyproject.toml index 84f9d113..36c98c30 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -51,6 +51,9 @@ ai = [ "anthropic>=0.49.0", "openai>=1.0.0", "google-generativeai>=0.8.0", + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", ] [dependency-groups] @@ -68,6 +71,9 @@ dev = [ "sqlalchemy[asyncio]>=2.0.38", "fastapi[standard]>=0.124.4", "faker>=40.13.0", + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", ] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 76c04f99..43701077 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -1,27 +1,11 @@ -"""FastAPI Startkit AI module. - -Provides a LangGraph-powered declarative API for building AI agents backed -by Anthropic, OpenAI, or Google provider SDKs. - -Also exposes a Laravel-style fluent API for image generation and text-to-speech:: - - from fastapi_startkit.ai import Image, Audio, Document - - image = await Image.of("A donut on a counter").generate() - - # With a photo attachment - doc = await Document.from_url("https://example.com/photo.jpg") - image = await Image.of("Make impressionist").attachments([doc]).generate() - - audio = await Audio.of("Hello world").female().generate() -""" - from .agent import Agent from .audio import Audio, AudioResponse from .audio_factory import AudioFactory -from .config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig +from .config.config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from .config.ai import AIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document +from .fakes import fake_chat_model from .image import Image, ImageResponse from .image_factory import ImageFactory from .providers.ai_provider import AIProvider @@ -38,6 +22,8 @@ "AudioResponse", "AudioFactory", "Document", + "ElevenLabsConfig", + "fake_chat_model", "GoogleConfig", "Image", "ImageFactory", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 05a7c664..804dd4a0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -1,4 +1,16 @@ -"""Agent base class — subclass this and apply decorators to build an AI agent.""" +"""Agent base class — subclass this and apply decorators to build an AI agent. + +The agent runs on LangChain/LangGraph: :meth:`Agent.prompt` builds a chat model +with ``init_chat_model`` and drives a ``create_agent`` loop (tools included), +while :meth:`Agent.stream` streams tokens straight from the model. The public +surface — ``prompt``/``stream``/``fake``/``assert_prompted``/``reset`` plus the +lifecycle hooks and decorators — is provider-agnostic; only the backend changed. + +Real calls need the ``langgraph`` extra plus the relevant provider integration +(e.g. ``langchain-anthropic``). Tests never need them: :meth:`fake` short-circuits +before the backend, and :func:`fastapi_startkit.ai.fakes.fake_chat_model` drives +the full agent loop offline. +""" from __future__ import annotations @@ -6,6 +18,7 @@ from typing import Any, Callable, Iterator, Optional, Type from .document import Document +from .lab import Lab from .response import AgentResponse, AgentSnapshot @@ -15,6 +28,7 @@ class Agent: Class-level configuration (set via decorators or subclass attributes):: + _instructions = "" # the agent's static system instructions _provider = "anthropic" # LLM provider _model = "" # model ID (empty = provider default) _max_steps = 10 # max agentic loop iterations @@ -24,6 +38,7 @@ class Agent: _memory_backend = "" # memory backend name (reserved) """ + _instructions: str = "" _provider: str = "anthropic" _model: str = "" _max_steps: int = 10 @@ -32,22 +47,16 @@ class Agent: _top_p: float = 1.0 _memory_backend: str = "" - _DEFAULT_MODELS: dict[str, str] = { - "anthropic": "claude-sonnet-4-6", - "openai": "gpt-4o", - "google": "gemini-2.0-flash", - } - def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} self._call_log: list[dict] = [] - # ── Lifecycle — override in subclasses ────────────────────────────────── - def messages(self) -> list[dict]: - """Return initial messages / few-shot examples.""" return [] + def instructions(self) -> str | None: + return None + def schema(self) -> Optional[Type]: """Return a Pydantic model class for structured output, or None for plain text.""" return None @@ -75,22 +84,18 @@ def after(self, response: AgentResponse) -> AgentResponse: # ── Public API ────────────────────────────────────────────────────────── def prompt( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: """Send a prompt and return an AgentResponse.""" message = self.before(message) _run_kwargs = dict( - system=system, model=model, - extra_messages=messages, attachments=attachments, provider_options=provider_options, ) @@ -112,12 +117,11 @@ def _call(msg: str) -> AgentResponse: return self.after(response) def stream( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: """Stream a response token by token.""" message = self.before(message) @@ -130,7 +134,7 @@ def stream( response = fake yield response.content return - yield from self._stream(message, system=system, model=model, provider_options=provider_options) + yield from self._stream(message, model=model, provider_options=provider_options) def fake(self, patterns: dict[str, AgentResponse | AgentSnapshot]) -> "Agent": """Register fake responses for testing. Keys are glob patterns.""" @@ -180,19 +184,9 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) - def _execute_tool(self, name: str, inputs: dict) -> Any: - """Find a tool by function name and call it with the given inputs.""" - for tool in self.tools(): - if callable(tool) and tool.__name__ == name: - return tool(**inputs) - raise ValueError(f"Tool {name!r} not found") - def _resolve_model(self, override: str | None = None) -> str: - if override: - return override - if self._model: - return self._model - return self._DEFAULT_MODELS.get(self._provider, "") + # Lab.get_model() returns the given model if truthy, else the config default. + return Lab(self._provider).get_model(override or self._model or None) def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -202,297 +196,99 @@ def _get_provider_options(self, override: dict | None = None) -> dict: options.update(provider_specific) return options + def _build_instruction(self) -> str | None: + return self._instructions or self.instructions() + def _build_messages( - self, - message: str, - system: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - ) -> tuple[str | None, list[dict]]: - base = self.messages() - - resolved_system = system - if resolved_system is None: - sys_entries = [m for m in base if m.get("role") == "system"] - if sys_entries: - resolved_system = sys_entries[0]["content"] - - history = [m for m in base if m.get("role") != "system"] - if extra_messages: - history.extend(extra_messages) + self, + message: str, + attachments: list[Document] | None = None, + ) -> list[dict]: + messages: list[dict] = [] - if attachments: - content: Any = [{"type": "text", "text": message}] - for doc in attachments: - if self._provider == "anthropic": - content.append(doc.to_anthropic_block()) - else: - content.append(doc.to_openai_block()) - history.append({"role": "user", "content": content}) - else: - history.append({"role": "user", "content": message}) + instruction = self._instructions or self.instructions() + if instruction: + messages.append({"role": "system", "content": instruction}) - return resolved_system, history + messages.extend(self.messages() or []) - def _run( - self, - message: str, - system: str | None = None, - model: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, - ) -> AgentResponse: - resolved_system, messages = self._build_messages(message, system, extra_messages, attachments) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - return self._run_anthropic(resolved_system, messages, resolved_model, options) - if self._provider == "openai": - return self._run_openai(resolved_system, messages, resolved_model, options) - if self._provider == "google": - return self._run_google(resolved_system, messages, resolved_model, options) - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + if message: + messages.append({"role": "user", "content": message}) - def _stream( - self, - message: str, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, - ) -> Iterator[str]: - resolved_system, messages = self._build_messages(message, system) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - yield from self._stream_anthropic(resolved_system, messages, resolved_model, options) - elif self._provider == "openai": - yield from self._stream_openai(resolved_system, messages, resolved_model, options) - elif self._provider == "google": - yield from self._stream_google(resolved_system, messages, resolved_model, options) - else: - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + if attachments: + content: Any = [{"type": "text", "text": message}] + for doc in attachments: + content.append(doc.to_langchain_block()) + messages.append({"role": "user", "content": content}) - # ── Anthropic ────────────────────────────────────────────────────────── + return messages - def _run_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - resp = client.messages.create(**params) - content = "".join(b.text for b in resp.content if hasattr(b, "text")) - return AgentResponse( - content=content, - usage={"input": resp.usage.input_tokens, "output": resp.usage.output_tokens}, - raw=resp, - ) + def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: + """Build a LangChain chat model for this agent. - def _stream_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - with client.messages.stream(**params) as stream: - for text in stream.text_stream: - yield text - - # ── OpenAI ───────────────────────────────────────────────────────────── - - def _run_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - **options, - } - resp = client.chat.completions.create(**params) - content = resp.choices[0].message.content or "" - return AgentResponse( - content=content, - usage={ - "input": resp.usage.prompt_tokens if resp.usage else 0, - "output": resp.usage.completion_tokens if resp.usage else 0, - }, - raw=resp, - ) + This is the seam tests patch to inject a fake chat model (see + :func:`fastapi_startkit.ai.fakes.fake_chat_model`). + """ + from langchain.chat_models import init_chat_model # noqa: PLC0415 - def _stream_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - "stream": True, - **options, - } - for chunk in client.chat.completions.create(**params): - delta = chunk.choices[0].delta.content - if delta: - yield delta - - def _resolve_api_key(self, provider_name: str) -> str | None: - """Try Config.get("ai") first, fallback to None (SDK reads env var).""" - try: - from fastapi_startkit.facades.Config import Config # noqa: PLC0415 - - ai_config = Config.get("ai") - return ai_config.providers[provider_name].key or None - except Exception: - return None - - # ── Google ───────────────────────────────────────────────────────────── - - def _run_google( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - import google.generativeai as genai # noqa: PLC0415 + lab = Lab(self._provider) + kwargs: dict[str, Any] = {"model_provider": lab.get_provider_key()} - api_key = self._resolve_api_key("google") + api_key = lab.get_api_key() if api_key: - genai.configure(api_key=api_key) - - generation_config: dict[str, Any] = {} + kwargs["api_key"] = api_key if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens + kwargs["max_tokens"] = self._max_tokens if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) + kwargs["top_p"] = self._top_p + if self._timeout: + kwargs["timeout"] = self._timeout + kwargs.update(self._get_provider_options(provider_options)) - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) + return init_chat_model(self._resolve_model(model), **kwargs) - google_messages = _to_google_messages(messages) - response = google_model.generate_content(google_messages) - content = response.text if hasattr(response, "text") else "" - usage: dict[str, Any] = {} - if hasattr(response, "usage_metadata"): - meta = response.usage_metadata - usage = { - "input": getattr(meta, "prompt_token_count", 0), - "output": getattr(meta, "candidates_token_count", 0), - } - return AgentResponse(content=content, usage=usage, raw=response) - - def _stream_google( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - import google.generativeai as genai # noqa: PLC0415 + def _to_agent_response(self, result: Any) -> AgentResponse: + """Map a ``create_agent`` invoke result to an AgentResponse.""" + messages = result.get("messages", []) if isinstance(result, dict) else [] + final = messages[-1] if messages else result - api_key = self._resolve_api_key("google") - if api_key: - genai.configure(api_key=api_key) + content = getattr(final, "content", "") + if not isinstance(content, str): + content = str(content) - generation_config: dict[str, Any] = {} - if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens - if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) + tool_calls = list(getattr(final, "tool_calls", None) or []) - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) + usage: dict[str, Any] = {} + meta = getattr(final, "usage_metadata", None) + if meta: + usage = {"input": meta.get("input_tokens", 0), "output": meta.get("output_tokens", 0)} - google_messages = _to_google_messages(messages) - for chunk in google_model.generate_content(google_messages, stream=True): - if chunk.text: - yield chunk.text + return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) + def _run( + self, + message: str, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, + ) -> AgentResponse: + from .runner import Runner # noqa: PLC0415 -# ─── Utilities ───────────────────────────────────────────────────────────────── + messages = self._build_messages(message, attachments) + chat_model = self._build_model(model, provider_options) + result = Runner(chat_model, self.tools(), self._max_steps).run(messages) + return self._to_agent_response(result) -def _to_google_messages(messages: list[dict]) -> list[dict]: - """ - Convert OpenAI-style messages to Google GenerativeAI content format. - Maps 'assistant' role → 'model'; omits 'system' (handled via system_instruction). - """ - result = [] - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - if role == "system": - continue # system_instruction is set at model-construction level - google_role = "model" if role == "assistant" else "user" - if isinstance(content, list): - # Multi-part content — extract text parts only - text = " ".join(p.get("text", "") for p in content if isinstance(p, dict) and "text" in p) - result.append({"role": google_role, "parts": [{"text": text}]}) - else: - result.append({"role": google_role, "parts": [{"text": str(content)}]}) - return result + def _stream( + self, + message: str, + model: str | None = None, + provider_options: dict | None = None, + ) -> Iterator[str]: + from .runner import StreamRunner # noqa: PLC0415 + + messages = self._build_messages(message) + chat_model = self._build_model(model, provider_options) + + yield from StreamRunner(chat_model, self.tools(), self._max_steps).run(messages) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio.py b/fastapi_startkit/src/fastapi_startkit/ai/audio.py index b709daff..a05c9bc7 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio.py @@ -89,7 +89,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Audio: """Fluent builder for text-to-speech generation. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.audio_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_audio` (env: ``AI_AUDIO_PROVIDER``). Defaults to OpenAI TTS. Usage:: @@ -191,7 +191,7 @@ def _resolve_provider(self) -> "AudioFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.audio_provider + provider_name = ai_config.default_audio openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py index 14c817b6..3613e5f3 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`AudioFactory` ABC so that the :class:`~fastapi_startkit.ai.Audio` builder is not hard-wired to a single vendor. Select the active provider via ``AI_AUDIO_PROVIDER`` in your -``.env`` (or ``AIConfig.audio_provider``). +``.env`` (or ``AIConfig.default_audio``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py new file mode 100644 index 00000000..1dda6a48 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from fastapi_startkit.environment import env + +from fastapi_startkit.ai import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig + + +@dataclass +class AIConfig: + """Top-level AI configuration — selects the default provider per modality and holds per-provider configs.""" + + default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) + default_image: str = field(default_factory=lambda: env("AI_DEFAULT_IMAGE_PROVIDER", "openai")) + default_audio: str = field(default_factory=lambda: env("AI_DEFAULT_AUDIO_PROVIDER", "openai")) + default_transcribe: str = field(default_factory=lambda: env("AI_DEFAULT_TRANSCRIBE_PROVIDER", "openai")) + + providers: dict = field( + default_factory=lambda: { + "google": GoogleConfig(), + "openai": OpenAIConfig(), + "anthropic": AnthropicConfig(), + "elevenlabs": ElevenLabsConfig(), + } + ) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py similarity index 55% rename from fastapi_startkit/src/fastapi_startkit/ai/config.py rename to fastapi_startkit/src/fastapi_startkit/ai/config/config.py index 16a74206..96bb5c67 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py @@ -1,4 +1,10 @@ -"""AI configuration dataclasses for the FastAPI Startkit AI module.""" +"""Per-provider AI configuration dataclasses. + +Each provider declares its ``driver``, credentials, and a ``models`` map of the +default model per modality (``default`` = text, plus ``default_image`` / +``default_audio`` / ``default_transcribe`` where the provider supports them). +:class:`~fastapi_startkit.ai.lab.Lab` resolves these via the ``Config`` store. +""" from __future__ import annotations @@ -15,6 +21,12 @@ class AnthropicConfig: key: str = field(default_factory=lambda: env("ANTHROPIC_API_KEY", "")) url: str = field(default_factory=lambda: env("ANTHROPIC_BASE_URL", "https://api.anthropic.com")) + models: dict = field( + default_factory=lambda: { + "default": "claude-sonnet-4-6", + } + ) + @dataclass class OpenAIConfig: @@ -24,6 +36,13 @@ class OpenAIConfig: key: str = field(default_factory=lambda: env("OPENAI_API_KEY", "")) url: str = field(default_factory=lambda: env("OPENAI_BASE_URL", "https://api.openai.com/v1")) + models: dict = field( + default_factory=lambda: { + "default": "gpt-4o", + "default_image": "dall-e-3", + } + ) + @dataclass class GoogleConfig: @@ -32,6 +51,13 @@ class GoogleConfig: driver: str = "google" key: str = field(default_factory=lambda: env("GEMINI_API_KEY", "") or env("GOOGLE_API_KEY", "")) + models: dict = field( + default_factory=lambda: { + "default": "gemini-2.0-flash", + "default_image": "imagen-3.0-generate-002", + } + ) + @dataclass class ElevenLabsConfig: @@ -40,22 +66,9 @@ class ElevenLabsConfig: driver: str = "elevenlabs" key: str = field(default_factory=lambda: env("ELEVENLABS_API_KEY", "")) - -@dataclass -class AIConfig: - """Top-level AI configuration — selects the default provider and holds per-provider configs.""" - - default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) - - providers: dict = field( + models: dict = field( default_factory=lambda: { - "openai": OpenAIConfig(), - "anthropic": AnthropicConfig(), - "google": GoogleConfig(), - "elevenlabs": ElevenLabsConfig(), + "default_audio": "eleven_multilingual_v2", + "default_transcribe": "scribe_v1", } ) - - # Media-generation provider selection - image_provider: str = field(default_factory=lambda: env("AI_IMAGE_PROVIDER", "openai")) - audio_provider: str = field(default_factory=lambda: env("AI_AUDIO_PROVIDER", "openai")) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py index 9ac7fdbb..574a5876 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/document.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py @@ -137,3 +137,16 @@ def to_openai_block(self) -> dict: "type": "text", "text": f"[Document: {self.name}]\n{self.content}", } + + def to_langchain_block(self) -> dict: + """Return a LangChain content block for this document. + + Text (or ``text/*`` media) is inlined as a labelled text part; everything + else becomes a base64 ``image``/``file`` block the model reads natively. + """ + is_text = isinstance(self.content, str) or self.media_type.startswith("text/") + if is_text: + text = self.content if isinstance(self.content, str) else self.content.decode("utf-8", "replace") + return {"type": "text", "text": f"[Document: {self.name}]\n{text}"} + block_type = "image" if self.media_type.startswith("image/") else "file" + return {"type": block_type, "base64": self.to_base64(), "mime_type": self.media_type} diff --git a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py new file mode 100644 index 00000000..430f02a9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py @@ -0,0 +1,59 @@ +"""LangChain test helpers — drive a real agent loop offline, without a provider. + +:func:`fake_chat_model` returns a chat model that replays a scripted sequence of +assistant turns. Inject it into an :class:`~fastapi_startkit.ai.Agent` by patching +``_build_model`` so :meth:`Agent.prompt` runs the genuine ``create_agent`` loop — +tool calls included — with no network. Requires the ``langgraph`` extra:: + + pip install "fastapi-startkit[langgraph]" + +Example — exercise a tool-calling agent end to end:: + + from langchain_core.messages import AIMessage, ToolCall + from fastapi_startkit.ai import fake_chat_model + + model = fake_chat_model([ + AIMessage(content="", tool_calls=[ + ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call"), + ]), + AIMessage(content="Here is a Python Developer role at Shopify."), + ]) + agent = JobAssistant() + agent._build_model = lambda *a, **k: model + response = agent.prompt("find me a python job") + assert response.content == "Here is a Python Developer role at Shopify." +""" + +from __future__ import annotations + +from typing import Any, Iterable + + +def _require_langchain(): + try: + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel + from langchain_core.messages import AIMessage + except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError( + "The agent test harness requires the 'langgraph' extra. " + 'Install it with: pip install "fastapi-startkit[langgraph]"' + ) from exc + return GenericFakeChatModel, AIMessage + + +def fake_chat_model(turns: Iterable[Any]): + """Return a fake chat model that replays ``turns`` in order. + + Each turn is an ``AIMessage`` (which may carry ``tool_calls``) or a ``str`` + (shorthand for ``AIMessage(content=...)``). The scripted turns already encode + the model's decisions, so ``bind_tools`` is a no-op — the bound tool schemas + don't change what the fake says next. + """ + generic_model, ai_message = _require_langchain() + + class _FakeChatModel(generic_model): + def bind_tools(self, tools, **kwargs): + return self + + normalized = [t if isinstance(t, ai_message) else ai_message(content=str(t)) for t in turns] + return _FakeChatModel(messages=iter(normalized)) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image.py b/fastapi_startkit/src/fastapi_startkit/ai/image.py index c6794293..ec11e28b 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image.py @@ -90,7 +90,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Image: """Fluent builder for image generation and editing. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.image_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_image` (env: ``AI_IMAGE_PROVIDER``). Defaults to OpenAI DALL-E. Usage — text to image:: @@ -198,7 +198,7 @@ def _resolve_provider(self) -> "ImageFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.image_provider + provider_name = ai_config.default_image openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py index b699dafa..b5bf4ba4 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`ImageFactory` ABC so that the :class:`~fastapi_startkit.ai.Image` builder is not hard-wired to a single vendor. Select the active provider via ``AI_IMAGE_PROVIDER`` in your -``.env`` (or ``AIConfig.image_provider``). +``.env`` (or ``AIConfig.default_image``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py new file mode 100644 index 00000000..b0369fb5 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -0,0 +1,59 @@ +from enum import StrEnum + +from fastapi_startkit import Config + + +class ModelType(StrEnum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + TRANSCRIBE = "transcribe" + + +def _model_key(model_type: ModelType) -> str: + """The key under a provider's ``models`` map for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +def _provider_field(model_type: ModelType) -> str: + """The ``ai`` config field selecting the default provider for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +class Lab(StrEnum): + GOOGLE = "google" + OPENAI = "openai" + ANTHROPIC = "anthropic" + ELEVENLABS = "elevenlabs" + + def get_api_key(self) -> str: + """Return this provider's configured API key.""" + return Config.get(f"ai.providers.{self.value}.key") + + def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: + return model or Config.get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + + def get_provider_key(self) -> str: + """Map this provider to its LangChain ``init_chat_model`` id.""" + return { + "anthropic": "anthropic", + "openai": "openai", + "google": "google_genai", + "elevenlabs": "elevenlabs", + }[self.value] + + @staticmethod + def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": + """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" + provider = provider or Config.get(f"ai.{_provider_field(model_type)}") + return Lab(provider) + + @staticmethod + def get_model_url( + provider: str | None = None, + model: str | None = None, + model_type: ModelType = ModelType.TEXT, + ) -> str: + """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" + lab = Lab.get_provider(provider, model_type) + return f"{lab.get_provider_key()}:{lab.get_model(model, model_type)}" diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py new file mode 100644 index 00000000..41b707b2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -0,0 +1,80 @@ +"""Runner — drive a chat model through a tool-calling loop, no ``create_agent``. + +The :class:`~fastapi_startkit.ai.agent.Agent` builds a chat model (via +``init_chat_model``) and its tools, then hands them to a Runner. The Runner binds +the tools, invokes the model, executes any tool calls the model requests, feeds +the results back, and repeats until the model answers without calling a tool (or +``max_steps`` is reached). :class:`StreamRunner` does the same while yielding +content tokens as they arrive. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool + +# A turn in the running history: a chat message or a plain role/content dict. +Message = BaseMessage | dict[str, Any] + + +class Runner: + """Run a chat model through a tool-calling loop and return the final message.""" + + def __init__( + self, + model: BaseChatModel, + tools: Sequence[BaseTool] | None = None, + max_steps: int = 10, + ) -> None: + self._tools: dict[str, BaseTool] = {tool.name: tool for tool in (tools or [])} + # Bind the tools so the model can request them; an unbound model otherwise. + self.model: Runnable[Any, BaseMessage] = ( + model.bind_tools(list(self._tools.values())) if self._tools else model + ) + self.max_steps = max_steps + + def run(self, messages: Sequence[Message]) -> AIMessage: + history: list[Message] = list(messages) + response: AIMessage = self.model.invoke(history) # type: ignore[assignment] + + for _ in range(self.max_steps): + if not response.tool_calls: + break + history.append(response) + history.extend(self._run_tools(response.tool_calls)) + response = self.model.invoke(history) # type: ignore[assignment] + + return response + + def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: + return [self._resolve_tool(call["name"]).invoke(call) for call in tool_calls] + + def _resolve_tool(self, name: str) -> BaseTool: + try: + return self._tools[name] + except KeyError: + raise ValueError(f"Agent has no tool named {name!r}") from None + + +class StreamRunner(Runner): + """Like :class:`Runner`, but yields content tokens as the model streams them.""" + + def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[override] + history: list[Message] = list(messages) + + for _ in range(self.max_steps): + gathered: AIMessageChunk | None = None + for chunk in self.model.stream(history): + if chunk.content: + yield chunk.content if isinstance(chunk.content, str) else str(chunk.content) + gathered = chunk if gathered is None else gathered + chunk # type: ignore[operator] + + if gathered is None or not gathered.tool_calls: + return + history.append(gathered) + history.extend(self._run_tools(gathered.tool_calls)) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py index 3f3a0613..4b6ab996 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py @@ -7,7 +7,19 @@ from .models import Model from .models.fields import CreatedAtField, DateTimeField, Field, ModelField, UpdatedAtField from .providers import DatabaseProvider -from .relationships import BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphTo +from .relationships import ( + BaseRelationship, + BelongsTo, + BelongsToMany, + HasMany, + HasManyThrough, + HasOne, + HasOneThrough, + MorphMany, + MorphOne, + MorphTo, + MorphToMany, +) __all__ = [ "DatabaseProvider", @@ -26,6 +38,7 @@ "UpdatedAtField", "Field", # Relationships + "BaseRelationship", "HasOne", "BelongsTo", "HasMany", @@ -33,4 +46,7 @@ "BelongsToMany", "HasOneThrough", "MorphTo", + "MorphMany", + "MorphOne", + "MorphToMany", ] 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/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 71431700..80102997 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 4e0e7c33..cf56b890 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -28,6 +28,25 @@ def register(self) -> None: self.app.bind("vite", vite) + self.register_templates(config) + + def register_templates(self, config: ViteConfig) -> None: + # Bind a Jinja2 template engine out of the box so fresh apps can render + # views and receive the vite() globals during boot. An existing + # "templates" binding always wins, keeping this backward-compatible. + if not config.template or self.app.has("templates"): + return + + try: + from starlette.templating import Jinja2Templates + except ImportError as exc: + raise ImportError( + "Rendering Vite templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]" + ) from exc + + templates_dir = self.app.base_path / config.templates_directory + self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) + def boot(self) -> None: vite: Vite = self.app.make("vite") config = self.app.make("config").get(self.provider_key) @@ -48,7 +67,7 @@ def boot(self) -> None: os.path.join(stubs_path, "tsconfig.json"): "tsconfig.json", os.path.join(stubs_path, "resources/js/app.ts"): "resources/js/app.ts", os.path.join(stubs_path, "resources/css/app.css"): "resources/css/app.css", - os.path.join(stubs_path, "templates/index.html"): "templates/index.html", + os.path.join(stubs_path, "resources/templates/index.html"): "resources/templates/index.html", } ) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html b/fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html rename to fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py new file mode 100644 index 00000000..a0aff3f8 --- /dev/null +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -0,0 +1,184 @@ +"""The Agent backend runs on LangGraph (create_agent), tested offline. + +These exercise the real ``_run``/``_stream`` path — no ``fake()`` short-circuit. +Instead of mutating the agent, we patch the single seam the backend uses to build +a model (``langchain.chat_models.init_chat_model``) with pytest's ``monkeypatch`` +and feed it a scripted :func:`fake_chat_model`. The public API is unchanged: +``prompt()`` still returns an AgentResponse and ``stream()`` still yields strings. +""" + +import langchain.chat_models as chat_models +import pytest +from langchain_core.messages import AIMessage, ToolCall +from langchain_core.tools import tool + +from fastapi_startkit.ai import Document, fake_chat_model +from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai.response import AgentResponse +from fastapi_startkit.application import app + + +@pytest.fixture(autouse=True) +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + + +@pytest.fixture +def scripted_model(monkeypatch): + """Replace the model the backend builds with a scripted fake, offline.""" + + def install(turns): + model = fake_chat_model(turns) + monkeypatch.setattr(chat_models, "init_chat_model", lambda *a, **k: model) + return model + + return install + + +@tool +def search_jobs(query: str) -> str: + """Search the job board for roles matching the query.""" + return "Python Developer at Shopify" + + +class JobAssistant(Agent): + _instructions = "You help users find jobs." + + def tools(self): + return [search_jobs] + + +# ─── prompt() drives the create_agent loop ──────────────────────────────────── + + +def test_prompt_returns_agent_response(scripted_model): + scripted_model([AIMessage(content="hello back")]) + + result = Agent().prompt("hi there") + + assert isinstance(result, AgentResponse) + assert result.content == "hello back" + Agent().assert_not_prompted() # a fresh instance has its own empty log + + +def test_prompt_runs_a_full_tool_calling_loop(scripted_model): + scripted_model( + [ + AIMessage( + content="", + tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], + ), + AIMessage(content="Here is a Python Developer role at Shopify."), + ] + ) + + result = JobAssistant().prompt("find me a python job") + + assert result.content == "Here is a Python Developer role at Shopify." + # The loop ran: user → AI(tool_call) → tool result → AI(final). + assert len(result.raw["messages"]) == 4 + + +def test_prompt_maps_usage_metadata(scripted_model): + scripted_model([AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})]) + + result = Agent().prompt("anything") + + assert result.usage == {"input": 11, "output": 7} + + +def test_build_model_passes_langchain_provider_key(monkeypatch): + captured = {} + + def fake_init(model, **kwargs): + captured["model"] = model + captured["provider"] = kwargs.get("model_provider") + return fake_chat_model([AIMessage(content="ok")]) + + monkeypatch.setattr(chat_models, "init_chat_model", fake_init) + + class GoogleAgent(Agent): + _provider = "google" + + GoogleAgent().prompt("hi") + + assert captured["provider"] == "google_genai" + assert captured["model"] == "gemini-2.0-flash" + + +# ─── streaming ──────────────────────────────────────────────────────────────── + + +def test_stream_yields_tokens_from_the_model(scripted_model): + scripted_model([AIMessage(content="streamed reply")]) + + chunks = list(Agent().stream("hello")) + + assert "".join(chunks) == "streamed reply" + + +# ─── model / message building (unit) ────────────────────────────────────────── + + +def test_resolve_model_falls_back_to_lab_default(): + assert Agent()._resolve_model() == "claude-sonnet-4-6" + + class GoogleAgent(Agent): + _provider = "google" + + assert GoogleAgent()._resolve_model() == "gemini-2.0-flash" + + +def test_resolve_model_prefers_explicit_override(): + assert Agent()._resolve_model("my-model") == "my-model" + + +def test_instructions_come_from_the_attribute_not_messages(): + agent = JobAssistant() + + resolved_system, history = agent._build_messages("find me a job") + + assert resolved_system == "You help users find jobs." + # messages() is conversation-only; no system entry leaks into history. + assert all(m.get("role") != "system" for m in history) + + +def test_instructions_can_be_a_method_override(): + class DynamicAgent(Agent): + def instructions(self) -> str: + return "Computed identity." + + resolved_system, _history = DynamicAgent()._build_messages("hi") + + assert resolved_system == "Computed identity." + + +def test_no_instructions_resolves_to_none(): + resolved_system, _history = Agent()._build_messages("hi") + + assert resolved_system is None + + +def test_build_messages_inlines_text_attachment(): + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + _system, history = Agent()._build_messages("Summarise this report.", attachments=[doc]) + + user_content = history[-1]["content"] + assert user_content[0] == {"type": "text", "text": "Summarise this report."} + assert user_content[1]["type"] == "text" + assert "q3-report.txt" in user_content[1]["text"] + + +def test_build_messages_encodes_binary_attachment_as_file_block(): + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") + + _system, history = Agent()._build_messages("Summarise", attachments=[doc]) + + block = history[-1]["content"][1] + assert block["type"] == "file" + assert block["mime_type"] == "application/pdf" + assert block["base64"] == doc.to_base64() diff --git a/fastapi_startkit/tests/ai/test_audio.py b/fastapi_startkit/tests/ai/test_audio.py index 0087de9f..4f14915b 100644 --- a/fastapi_startkit/tests/ai/test_audio.py +++ b/fastapi_startkit/tests/ai/test_audio.py @@ -248,7 +248,7 @@ def test_unknown_voice_passed_through(self): async def test_audio_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "google" + mock_ai_config.default_audio = "google" mock_ai_config.providers = { "google": MagicMock(key="gkey"), "openai": MagicMock(key=""), @@ -327,7 +327,7 @@ def test_direct_voice_id_passed_through(self): async def test_audio_builder_resolves_elevenlabs_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "elevenlabs" + mock_ai_config.default_audio = "elevenlabs" mock_ai_config.providers = { "google": MagicMock(key=""), "openai": MagicMock(key=""), diff --git a/fastapi_startkit/tests/ai/test_image.py b/fastapi_startkit/tests/ai/test_image.py index 4c98c3df..c7ca7a01 100644 --- a/fastapi_startkit/tests/ai/test_image.py +++ b/fastapi_startkit/tests/ai/test_image.py @@ -283,7 +283,7 @@ async def test_edit_raises_not_implemented(self): async def test_image_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.image_provider = "google" + mock_ai_config.default_image = "google" mock_ai_config.providers = {"google": MagicMock(key="gkey"), "openai": MagicMock(key="")} with patch("fastapi_startkit.ai.image.Config") as mock_config: diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py new file mode 100644 index 00000000..1e344a1e --- /dev/null +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -0,0 +1,51 @@ +"""Lab resolves the active provider, model, and LangChain model URL from config.""" + +import pytest + +from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai.lab import Lab, ModelType +from fastapi_startkit.application import app + + +@pytest.fixture +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + return container + + +def test_provider_key_maps_google_to_genai(): + assert Lab.GOOGLE.get_provider_key() == "google_genai" + assert Lab.OPENAI.get_provider_key() == "openai" + assert Lab.ANTHROPIC.get_provider_key() == "anthropic" + assert Lab.ELEVENLABS.get_provider_key() == "elevenlabs" + + +def test_get_model_returns_explicit_override(): + # No config needed — an explicit model short-circuits the lookup. + assert Lab.GOOGLE.get_model("custom-model") == "custom-model" + + +def test_get_model_text_default(ai_config): + assert Lab.GOOGLE.get_model() == "gemini-2.0-flash" + assert Lab.ANTHROPIC.get_model() == "claude-sonnet-4-6" + assert Lab.OPENAI.get_model() == "gpt-4o" + + +def test_get_model_image_default(ai_config): + assert Lab.OPENAI.get_model(model_type=ModelType.IMAGE) == "dall-e-3" + assert Lab.GOOGLE.get_model(model_type=ModelType.IMAGE) == "imagen-3.0-generate-002" + + +def test_get_provider_uses_config_default(ai_config): + assert Lab.get_provider().value == "google" + + +def test_get_provider_explicit_wins(): + assert Lab.get_provider("anthropic") is Lab.ANTHROPIC + + +def test_get_model_url_text(ai_config): + assert Lab.get_model_url() == "google_genai:gemini-2.0-flash" + assert Lab.get_model_url("anthropic") == "anthropic:claude-sonnet-4-6" diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/model.py b/fastapi_startkit/tests/masoniteorm/fixtures/model.py index 167343f4..5abbc181 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/model.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/model.py @@ -3,7 +3,7 @@ from fastapi_startkit.carbon.carbon import Carbon from tests.masoniteorm.fixtures.casts import Address from fastapi_startkit.masoniteorm import ModelField, Field -from fastapi_startkit.masoniteorm.relationships import ( +from fastapi_startkit.masoniteorm import ( HasOne, BelongsTo, HasMany, diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py index 1aaed80a..0e6efe60 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py @@ -1,5 +1,5 @@ from fastapi_startkit.masoniteorm.models.model import Model -from fastapi_startkit.masoniteorm.relationships import HasOne, BelongsTo +from fastapi_startkit.masoniteorm import HasOne, BelongsTo from ..test_case import TestCase diff --git a/fastapi_startkit/tests/vite/test_template_binding.py b/fastapi_startkit/tests/vite/test_template_binding.py new file mode 100644 index 00000000..20060339 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_template_binding.py @@ -0,0 +1,64 @@ +import sys +from unittest import mock + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.providers import Provider +from fastapi_startkit.vite import ViteProvider +from fastapi_startkit.vite.config.vite import ViteConfig + + +def make_app(tmp_path, providers=None) -> Application: + if providers is None: + providers = [ViteProvider] + return Application(base_path=tmp_path, env="testing", providers=providers) + + +class _SentinelTemplatesProvider(Provider): + provider_key = "sentinel_templates" + + def register(self) -> None: + self.app.bind("templates", "SENTINEL") + + +class TestTemplateBinding: + def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path): + from starlette.templating import Jinja2Templates + + app = make_app(tmp_path) + + assert app.has("templates") + assert isinstance(app.make("templates"), Jinja2Templates) + + def test_respects_existing_templates_binding(self, tmp_path): + app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider]) + + assert app.make("templates") == "SENTINEL" + + def test_skips_binding_when_template_disabled(self, tmp_path): + app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) + + assert not app.has("templates") + + def test_vite_globals_injected_after_boot(self, tmp_path): + app = make_app(tmp_path) + + env_globals = app.make("templates").env.globals + assert "vite" in env_globals + assert "vite_asset" in env_globals + assert "vite_react_refresh" in env_globals + + +class TestMissingJinja2: + def test_missing_jinja2_raises_clear_install_error(self, tmp_path): + app = make_app(tmp_path, providers=[]) + provider = ViteProvider(app) + + # Jinja2Templates is only importable from starlette.templating when + # jinja2 is installed; setting the module to None makes the import fail. + with mock.patch.dict(sys.modules, {"starlette.templating": None}): + with pytest.raises(ImportError) as exc_info: + provider.register_templates(ViteConfig()) + + assert "fastapi-startkit[vite]" in str(exc_info.value) 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 "