From 3b8761e6aa242e36eee27039209c5db62c0e6472 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Sat, 27 Dec 2025 01:37:24 +0800 Subject: [PATCH 01/11] Add /health endpoint, enable Swagger docs, list Air routes as pages Introduces a default /health endpoint returning status 'ok' as JSON in Air applications. Updates default OpenAPI metadata and tags, and adds a test to verify the health endpoint. --- src/air/applications.py | 60 ++++++++++++++++++++++++++++++++++---- src/air/cli.py | 2 ++ tests/test_applications.py | 10 +++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/air/applications.py b/src/air/applications.py index e3ff20cfd..4aee9ff75 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -7,13 +7,15 @@ from enum import Enum from functools import wraps from typing import Annotated, Any, Literal +from importlib.metadata import version as get_version +from typing import Annotated, Any, Literal, TypeVar from warnings import deprecated from fastapi import FastAPI, routing from fastapi.params import Depends from fastapi.utils import generate_unique_id from starlette.middleware import Middleware -from starlette.responses import Response +from starlette.responses import JSONResponse, Response from starlette.routing import BaseRoute from starlette.types import Lifespan, Receive, Scope, Send from typing_extensions import Doc @@ -23,6 +25,12 @@ from .routing import AirRoute, AirRouter, RouteCallable, RouterMixin from .types import MaybeAwaitable +AIR_VERSION = get_version("air") +FASTAPI_VERSION = get_version("fastapi") +DEFAULT_DESCRIPTION = f"Built with Air {AIR_VERSION} on FastAPI {FASTAPI_VERSION}" +DEFAULT_TAGS: list[str | Enum] = ["pages"] + +AppType = TypeVar("AppType", bound="Air") class Air(RouterMixin): """Air web framework - HTML-first web apps powered by FastAPI. @@ -70,6 +78,31 @@ def __init__( """ ), ] = False, + title: Annotated[ + str, + Doc( + """ + The title of the API, shown in the OpenAPI documentation. + """ + ), + ] = "Air", + version: Annotated[ + str, + Doc( + """ + The version of the API, shown in the OpenAPI documentation. + """ + ), + ] = AIR_VERSION, + description: Annotated[ + str, + Doc( + """ + A description of the API, shown in the OpenAPI documentation. + Supports Markdown. + """ + ), + ] = DEFAULT_DESCRIPTION, routes: Annotated[ list[BaseRoute] | None, Doc( @@ -445,6 +478,21 @@ async def api_get_users(): # Route Decorators - Clean API without response_model clutter # ========================================================================= + # Register built-in health endpoint + self._register_health_endpoint() + + def _register_health_endpoint(self) -> None: + """Register the built-in /health endpoint.""" + + @self.get("/health", response_class=JSONResponse, tags=["health"]) + def health() -> dict[str, str]: + """Health check endpoint. + + Returns: + A JSON object with status "ok". + """ + return {"status": "ok"} + def get( self, path: Annotated[ @@ -483,7 +531,7 @@ def get( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = None, + ] = DEFAULT_TAGS, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -768,7 +816,7 @@ def post( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = None, + ] = DEFAULT_TAGS, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1067,7 +1115,7 @@ def patch( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = None, + ] = DEFAULT_TAGS, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1326,7 +1374,7 @@ def put( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = None, + ] = DEFAULT_TAGS, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1584,7 +1632,7 @@ def delete( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = None, + ] = DEFAULT_TAGS, dependencies: Annotated[ Sequence[Depends] | None, Doc( diff --git a/src/air/cli.py b/src/air/cli.py index 37571e6ca..290879724 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -95,11 +95,13 @@ def run( # Print startup banner url = f"http://{host}:{port}" + docs_url = f"{url}/docs" console.print() console.print(f" [bold cyan]Air[/bold cyan] v{version('air')}") console.print() console.print(f" [dim]➜[/dim] [bold]App:[/bold] {app_path}") console.print(f" [dim]➜[/dim] [bold]Server:[/bold] [link={url}]{url}[/link]") + console.print(f" [dim]➜[/dim] [bold]Docs:[/bold] [link={docs_url}]{docs_url}[/link]") console.print() uvicorn.run( diff --git a/tests/test_applications.py b/tests/test_applications.py index 47f936a4b..79ae0ee01 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -359,3 +359,13 @@ def test_fastapi_app_property() -> None: assert isinstance(app.fastapi_app, FastAPI) assert app.fastapi_app is app._app +def test_health_endpoint() -> None: + """Test that Air apps have a built-in /health endpoint.""" + app = air.Air() + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "ok"} From 9495512025390bf717f6e8bdaf25f5234025b799 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Sat, 27 Dec 2025 02:04:52 +0800 Subject: [PATCH 02/11] Update internal API docs URL to /_docs and revise default description Changed the default API documentation URL from /docs to /_docs in both the application and CLI. Updated the default description to reference FastAPI and link to the Air documentation site. --- src/air/applications.py | 2 +- src/air/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/air/applications.py b/src/air/applications.py index 4aee9ff75..0661588a1 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -27,7 +27,7 @@ AIR_VERSION = get_version("air") FASTAPI_VERSION = get_version("fastapi") -DEFAULT_DESCRIPTION = f"Built with Air {AIR_VERSION} on FastAPI {FASTAPI_VERSION}" +DEFAULT_DESCRIPTION = f"Built on FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" DEFAULT_TAGS: list[str | Enum] = ["pages"] AppType = TypeVar("AppType", bound="Air") diff --git a/src/air/cli.py b/src/air/cli.py index 290879724..16314e50f 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -95,7 +95,7 @@ def run( # Print startup banner url = f"http://{host}:{port}" - docs_url = f"{url}/docs" + docs_url = f"{url}/_docs" console.print() console.print(f" [bold cyan]Air[/bold cyan] v{version('air')}") console.print() From 9443b42d8310dbc758755dee06fb6d73cee1093a Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Sat, 27 Dec 2025 02:20:10 +0800 Subject: [PATCH 03/11] Update `air run` with both new API docs URLs --- src/air/cli.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/air/cli.py b/src/air/cli.py index 16314e50f..015bccecd 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -95,13 +95,15 @@ def run( # Print startup banner url = f"http://{host}:{port}" - docs_url = f"{url}/_docs" + swagger_url = f"{url}/_swagger" + redoc_url = f"{url}/_redoc" console.print() console.print(f" [bold cyan]Air[/bold cyan] v{version('air')}") console.print() - console.print(f" [dim]➜[/dim] [bold]App:[/bold] {app_path}") - console.print(f" [dim]➜[/dim] [bold]Server:[/bold] [link={url}]{url}[/link]") - console.print(f" [dim]➜[/dim] [bold]Docs:[/bold] [link={docs_url}]{docs_url}[/link]") + console.print(f" [dim]➜[/dim] [bold]App:[/bold] {app_path}") + console.print(f" [dim]➜[/dim] [bold]Server:[/bold] [link={url}]{url}[/link]") + console.print(f" [dim]➜[/dim] [bold]API docs:[/bold] [link={swagger_url}]{swagger_url}[/link]") + console.print(f" [dim]➜[/dim] [link={redoc_url}]{redoc_url}[/link]") console.print() uvicorn.run( From e12b61b985e23ed5cb0cd54407f1f0f1cbc08ad1 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 00:02:29 +0800 Subject: [PATCH 04/11] Move default URLs to constants --- src/air/applications.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/air/applications.py b/src/air/applications.py index 0661588a1..ea9399958 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -29,6 +29,9 @@ FASTAPI_VERSION = get_version("fastapi") DEFAULT_DESCRIPTION = f"Built on FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" DEFAULT_TAGS: list[str | Enum] = ["pages"] +DEFAULT_SWAGGER_URL = "/_swagger" +DEFAULT_REDOC_URL = "/_redoc" +DEFAULT_OPENAPI_URL = "/openapi.json" AppType = TypeVar("AppType", bound="Air") From 6b2c3bd9b865a44f3a69918ceb1e03688994abf3 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 00:46:05 +0800 Subject: [PATCH 05/11] Selectively move shared constants --- src/air/applications.py | 4 +--- src/air/cli.py | 11 ++++++----- src/air/constants.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 src/air/constants.py diff --git a/src/air/applications.py b/src/air/applications.py index ea9399958..a9ef5ae0d 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -20,17 +20,15 @@ from starlette.types import Lifespan, Receive, Scope, Send from typing_extensions import Doc +from .constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL from .exception_handlers import DEFAULT_EXCEPTION_HANDLERS, ExceptionHandlersType from .responses import AirResponse from .routing import AirRoute, AirRouter, RouteCallable, RouterMixin from .types import MaybeAwaitable -AIR_VERSION = get_version("air") FASTAPI_VERSION = get_version("fastapi") DEFAULT_DESCRIPTION = f"Built on FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" DEFAULT_TAGS: list[str | Enum] = ["pages"] -DEFAULT_SWAGGER_URL = "/_swagger" -DEFAULT_REDOC_URL = "/_redoc" DEFAULT_OPENAPI_URL = "/openapi.json" AppType = TypeVar("AppType", bound="Air") diff --git a/src/air/cli.py b/src/air/cli.py index 015bccecd..d6aa6e612 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -1,7 +1,6 @@ """Air CLI - Command-line interface for running Air applications.""" import sys -from importlib.metadata import version from pathlib import Path from typing import Annotated @@ -9,6 +8,8 @@ import uvicorn from rich.console import Console +from air.constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL + app = typer.Typer(add_completion=False, rich_markup_mode="rich") console = Console() @@ -37,7 +38,7 @@ def _version_callback(value: bool) -> None: # noqa: FBT001 - Typer callback signature if value: - typer.echo(f"Air {version('air')}\nCrafted with care by Two Scoops authors pydanny and audreyfeldroy") + typer.echo(f"Air {AIR_VERSION}\nCrafted with care by Two Scoops authors pydanny and audreyfeldroy") raise typer.Exit @@ -95,10 +96,10 @@ def run( # Print startup banner url = f"http://{host}:{port}" - swagger_url = f"{url}/_swagger" - redoc_url = f"{url}/_redoc" + swagger_url = f"{url}{DEFAULT_SWAGGER_URL}" + redoc_url = f"{url}{DEFAULT_REDOC_URL}" console.print() - console.print(f" [bold cyan]Air[/bold cyan] v{version('air')}") + console.print(f" [bold cyan]Air[/bold cyan] v{AIR_VERSION}") console.print() console.print(f" [dim]➜[/dim] [bold]App:[/bold] {app_path}") console.print(f" [dim]➜[/dim] [bold]Server:[/bold] [link={url}]{url}[/link]") diff --git a/src/air/constants.py b/src/air/constants.py new file mode 100644 index 000000000..0471edb2c --- /dev/null +++ b/src/air/constants.py @@ -0,0 +1,10 @@ +"""Air framework constants shared across modules.""" + +from importlib.metadata import version as get_version + +# Version +AIR_VERSION = get_version("air") + +# OpenAPI documentation URL defaults +DEFAULT_SWAGGER_URL = "/_swagger" +DEFAULT_REDOC_URL = "/_redoc" From 04d5dfcccfabe28ef0a0d7d776e1a02f6c44a743 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 00:47:28 +0800 Subject: [PATCH 06/11] Update module docstring in constants.py Revised the docstring to clarify the purpose of shared constants and note that module-specific constants are kept with their respective consumers. --- src/air/constants.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/air/constants.py b/src/air/constants.py index 0471edb2c..ea43bd6a3 100644 --- a/src/air/constants.py +++ b/src/air/constants.py @@ -1,4 +1,8 @@ -"""Air framework constants shared across modules.""" +"""Shared constants used across multiple Air modules. + +Module-specific constants (like DEFAULT_TAGS in applications.py +or LOG_CONFIG in cli.py) are kept with their consumers for locality. +""" from importlib.metadata import version as get_version From 985d07cd15b3945a3637844e369c2fc9c1e01823 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 01:05:56 +0800 Subject: [PATCH 07/11] Refactor constants Moved default title and attribution strings to constants.py for reuse. Updated application and CLI to use these constants, and improved the default API description for clarity and branding consistency. --- src/air/applications.py | 9 ++++++--- src/air/cli.py | 4 ++-- src/air/constants.py | 6 +++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/air/applications.py b/src/air/applications.py index a9ef5ae0d..87c0870df 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -20,14 +20,14 @@ from starlette.types import Lifespan, Receive, Scope, Send from typing_extensions import Doc -from .constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL +from .constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL, DEFAULT_TITLE from .exception_handlers import DEFAULT_EXCEPTION_HANDLERS, ExceptionHandlersType from .responses import AirResponse from .routing import AirRoute, AirRouter, RouteCallable, RouterMixin from .types import MaybeAwaitable FASTAPI_VERSION = get_version("fastapi") -DEFAULT_DESCRIPTION = f"Built on FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" +DEFAULT_DESCRIPTION = f"Built for clarity and joy • FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" DEFAULT_TAGS: list[str | Enum] = ["pages"] DEFAULT_OPENAPI_URL = "/openapi.json" @@ -86,7 +86,7 @@ def __init__( The title of the API, shown in the OpenAPI documentation. """ ), - ] = "Air", + ] = DEFAULT_TITLE, version: Annotated[ str, Doc( @@ -317,6 +317,9 @@ def about(): if fastapi_app is None: self._app = FastAPI( debug=debug, + title=title, + version=version, + description=description, routes=routes, servers=servers, dependencies=dependencies, diff --git a/src/air/cli.py b/src/air/cli.py index d6aa6e612..18be978ca 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -8,7 +8,7 @@ import uvicorn from rich.console import Console -from air.constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL +from air.constants import AIR_VERSION, ATTRIBUTION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL app = typer.Typer(add_completion=False, rich_markup_mode="rich") console = Console() @@ -38,7 +38,7 @@ def _version_callback(value: bool) -> None: # noqa: FBT001 - Typer callback signature if value: - typer.echo(f"Air {AIR_VERSION}\nCrafted with care by Two Scoops authors pydanny and audreyfeldroy") + typer.echo(f"Air {AIR_VERSION}\n{ATTRIBUTION}") raise typer.Exit diff --git a/src/air/constants.py b/src/air/constants.py index ea43bd6a3..0000a9455 100644 --- a/src/air/constants.py +++ b/src/air/constants.py @@ -9,6 +9,10 @@ # Version AIR_VERSION = get_version("air") -# OpenAPI documentation URL defaults +# Configuration defaults +DEFAULT_TITLE = "Air" DEFAULT_SWAGGER_URL = "/_swagger" DEFAULT_REDOC_URL = "/_redoc" + +# Brand voice +ATTRIBUTION = "Crafted with care by Two Scoops authors pydanny and audreyfeldroy" From c5c6188f4b35972fe98e2a4ec4ef56bdf20aabfd Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 01:07:13 +0800 Subject: [PATCH 08/11] Run `just qa` --- src/air/applications.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/air/applications.py b/src/air/applications.py index 87c0870df..8cee0056a 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -27,7 +27,9 @@ from .types import MaybeAwaitable FASTAPI_VERSION = get_version("fastapi") -DEFAULT_DESCRIPTION = f"Built for clarity and joy • FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" +DEFAULT_DESCRIPTION = ( + f"Built for clarity and joy • FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" +) DEFAULT_TAGS: list[str | Enum] = ["pages"] DEFAULT_OPENAPI_URL = "/openapi.json" From 2a96fb210a3668cd06aebbddd7761529ae0f8de0 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Mon, 29 Dec 2025 06:53:02 +0800 Subject: [PATCH 09/11] Move to composition --- src/air/applications.py | 66 ++++---------------------------------- src/air/cli.py | 49 ++++++++++++++++++++++++++-- tests/test_applications.py | 2 ++ 3 files changed, 55 insertions(+), 62 deletions(-) diff --git a/src/air/applications.py b/src/air/applications.py index 8cee0056a..e3ff20cfd 100644 --- a/src/air/applications.py +++ b/src/air/applications.py @@ -7,33 +7,22 @@ from enum import Enum from functools import wraps from typing import Annotated, Any, Literal -from importlib.metadata import version as get_version -from typing import Annotated, Any, Literal, TypeVar from warnings import deprecated from fastapi import FastAPI, routing from fastapi.params import Depends from fastapi.utils import generate_unique_id from starlette.middleware import Middleware -from starlette.responses import JSONResponse, Response +from starlette.responses import Response from starlette.routing import BaseRoute from starlette.types import Lifespan, Receive, Scope, Send from typing_extensions import Doc -from .constants import AIR_VERSION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL, DEFAULT_TITLE from .exception_handlers import DEFAULT_EXCEPTION_HANDLERS, ExceptionHandlersType from .responses import AirResponse from .routing import AirRoute, AirRouter, RouteCallable, RouterMixin from .types import MaybeAwaitable -FASTAPI_VERSION = get_version("fastapi") -DEFAULT_DESCRIPTION = ( - f"Built for clarity and joy • FastAPI {FASTAPI_VERSION} • [Docs](https://docs.airwebframework.org/)" -) -DEFAULT_TAGS: list[str | Enum] = ["pages"] -DEFAULT_OPENAPI_URL = "/openapi.json" - -AppType = TypeVar("AppType", bound="Air") class Air(RouterMixin): """Air web framework - HTML-first web apps powered by FastAPI. @@ -81,31 +70,6 @@ def __init__( """ ), ] = False, - title: Annotated[ - str, - Doc( - """ - The title of the API, shown in the OpenAPI documentation. - """ - ), - ] = DEFAULT_TITLE, - version: Annotated[ - str, - Doc( - """ - The version of the API, shown in the OpenAPI documentation. - """ - ), - ] = AIR_VERSION, - description: Annotated[ - str, - Doc( - """ - A description of the API, shown in the OpenAPI documentation. - Supports Markdown. - """ - ), - ] = DEFAULT_DESCRIPTION, routes: Annotated[ list[BaseRoute] | None, Doc( @@ -319,9 +283,6 @@ def about(): if fastapi_app is None: self._app = FastAPI( debug=debug, - title=title, - version=version, - description=description, routes=routes, servers=servers, dependencies=dependencies, @@ -484,21 +445,6 @@ async def api_get_users(): # Route Decorators - Clean API without response_model clutter # ========================================================================= - # Register built-in health endpoint - self._register_health_endpoint() - - def _register_health_endpoint(self) -> None: - """Register the built-in /health endpoint.""" - - @self.get("/health", response_class=JSONResponse, tags=["health"]) - def health() -> dict[str, str]: - """Health check endpoint. - - Returns: - A JSON object with status "ok". - """ - return {"status": "ok"} - def get( self, path: Annotated[ @@ -537,7 +483,7 @@ def get( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = DEFAULT_TAGS, + ] = None, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -822,7 +768,7 @@ def post( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = DEFAULT_TAGS, + ] = None, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1121,7 +1067,7 @@ def patch( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = DEFAULT_TAGS, + ] = None, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1380,7 +1326,7 @@ def put( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = DEFAULT_TAGS, + ] = None, dependencies: Annotated[ Sequence[Depends] | None, Doc( @@ -1638,7 +1584,7 @@ def delete( [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). """ ), - ] = DEFAULT_TAGS, + ] = None, dependencies: Annotated[ Sequence[Depends] | None, Doc( diff --git a/src/air/cli.py b/src/air/cli.py index 18be978ca..7f84b4f7f 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -1,11 +1,14 @@ """Air CLI - Command-line interface for running Air applications.""" +import importlib import sys +from collections.abc import Callable from pathlib import Path -from typing import Annotated +from typing import Annotated, Any, cast import typer import uvicorn +from fastapi import FastAPI from rich.console import Console from air.constants import AIR_VERSION, ATTRIBUTION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL @@ -42,6 +45,40 @@ def _version_callback(value: bool) -> None: # noqa: FBT001 - Typer callback sig raise typer.Exit +def _load_app(app_path: str) -> FastAPI | Callable[..., Any] | object: + """Load the ASGI app from a ``module:attr`` path or a Python file. + + If the loaded object is callable, it will be invoked to obtain the app. + + Returns: + The loaded application object (FastAPI instance or ASGI callable). + """ + module_path, attr_name = app_path.split(":", 1) + module = importlib.import_module(module_path) + print(f"{module=}") + obj = getattr(module, attr_name) + print(f"{obj=}") + return obj() if callable(obj) else obj + + +def _add_healthcheck_route(app_obj: object) -> None: + """Add a simple `/healthcheck` route when the app is a FastAPI instance.""" + if isinstance(app_obj, FastAPI): + + def _healthcheck() -> dict[str, str]: + return {"status": "ok"} + + # Keep healthcheck out of schema to avoid clutter; change as needed + app_obj.add_api_route( + "/healthcheck", + _healthcheck, + methods=["GET"], + tags=["Health"], + include_in_schema=False, + ) + raise Exception("blarg") + + @app.callback(invoke_without_command=True) def _callback( ctx: typer.Context, @@ -107,8 +144,16 @@ def run( console.print(f" [dim]➜[/dim] [link={redoc_url}]{redoc_url}[/link]") console.print() + # Import the app so we can modify it (e.g., add healthcheck) + try: + loaded_app: FastAPI | Callable[..., Any] | str | object = _load_app(app_path) + _add_healthcheck_route(loaded_app) + except (ImportError, AttributeError, TypeError) as exc: # Fallback: run by path if loading fails + console.print(f"[yellow]Warning:[/yellow] Could not pre-load app ({exc!r}); running by path.") + loaded_app = app_path + uvicorn.run( - app_path, + cast(Any, loaded_app), host=host, port=port, reload=reload, diff --git a/tests/test_applications.py b/tests/test_applications.py index 79ae0ee01..71ad221b6 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -359,6 +359,8 @@ def test_fastapi_app_property() -> None: assert isinstance(app.fastapi_app, FastAPI) assert app.fastapi_app is app._app + + def test_health_endpoint() -> None: """Test that Air apps have a built-in /health endpoint.""" app = air.Air() From c05653de9c36e60060d783e823b0236c084f32f5 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Sat, 3 Jan 2026 13:22:28 +0800 Subject: [PATCH 10/11] New minimal example We need this in order to test the Air CLI --- examples/hello_world.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 examples/hello_world.py diff --git a/examples/hello_world.py b/examples/hello_world.py new file mode 100644 index 000000000..8d42d64d3 --- /dev/null +++ b/examples/hello_world.py @@ -0,0 +1,7 @@ +import air + +app = air.Air() + +@app.page +def index(): + return air.H1('hello world') From 88d102cc497d3d44bfa3cb1db79b40996f231ce7 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Sat, 3 Jan 2026 13:22:45 +0800 Subject: [PATCH 11/11] Remove custom app loading and healthcheck logic Eliminated the internal _load_app and _add_healthcheck_route functions from the CLI. The run command now passes the app path directly to uvicorn, simplifying the startup process and removing custom FastAPI healthcheck injection. --- src/air/cli.py | 49 ++----------------------------------------------- 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/src/air/cli.py b/src/air/cli.py index 7f84b4f7f..18be978ca 100644 --- a/src/air/cli.py +++ b/src/air/cli.py @@ -1,14 +1,11 @@ """Air CLI - Command-line interface for running Air applications.""" -import importlib import sys -from collections.abc import Callable from pathlib import Path -from typing import Annotated, Any, cast +from typing import Annotated import typer import uvicorn -from fastapi import FastAPI from rich.console import Console from air.constants import AIR_VERSION, ATTRIBUTION, DEFAULT_REDOC_URL, DEFAULT_SWAGGER_URL @@ -45,40 +42,6 @@ def _version_callback(value: bool) -> None: # noqa: FBT001 - Typer callback sig raise typer.Exit -def _load_app(app_path: str) -> FastAPI | Callable[..., Any] | object: - """Load the ASGI app from a ``module:attr`` path or a Python file. - - If the loaded object is callable, it will be invoked to obtain the app. - - Returns: - The loaded application object (FastAPI instance or ASGI callable). - """ - module_path, attr_name = app_path.split(":", 1) - module = importlib.import_module(module_path) - print(f"{module=}") - obj = getattr(module, attr_name) - print(f"{obj=}") - return obj() if callable(obj) else obj - - -def _add_healthcheck_route(app_obj: object) -> None: - """Add a simple `/healthcheck` route when the app is a FastAPI instance.""" - if isinstance(app_obj, FastAPI): - - def _healthcheck() -> dict[str, str]: - return {"status": "ok"} - - # Keep healthcheck out of schema to avoid clutter; change as needed - app_obj.add_api_route( - "/healthcheck", - _healthcheck, - methods=["GET"], - tags=["Health"], - include_in_schema=False, - ) - raise Exception("blarg") - - @app.callback(invoke_without_command=True) def _callback( ctx: typer.Context, @@ -144,16 +107,8 @@ def run( console.print(f" [dim]➜[/dim] [link={redoc_url}]{redoc_url}[/link]") console.print() - # Import the app so we can modify it (e.g., add healthcheck) - try: - loaded_app: FastAPI | Callable[..., Any] | str | object = _load_app(app_path) - _add_healthcheck_route(loaded_app) - except (ImportError, AttributeError, TypeError) as exc: # Fallback: run by path if loading fails - console.print(f"[yellow]Warning:[/yellow] Could not pre-load app ({exc!r}); running by path.") - loaded_app = app_path - uvicorn.run( - cast(Any, loaded_app), + app_path, host=host, port=port, reload=reload,