From 20bf143d7941ca75e11d61e29c7a4d049cb31305 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 4 Jul 2025 10:25:29 +0100 Subject: [PATCH 1/2] fastapi example --- .gitignore | 2 + fastapi-example/index.html | 222 +++++++++++++++++++++++++++++++++++++ fastapi-example/main.py | 62 +++++++++++ pai-weather/main.py | 2 + pyproject.toml | 3 +- uv.lock | 71 +++++++++++- 6 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 fastapi-example/index.html create mode 100644 fastapi-example/main.py diff --git a/.gitignore b/.gitignore index ad78031..08201a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ wheels/ # Virtual environments .venv *.svg + +/fastapi-example/images/ diff --git a/fastapi-example/index.html b/fastapi-example/index.html new file mode 100644 index 0000000..ef6fbaa --- /dev/null +++ b/fastapi-example/index.html @@ -0,0 +1,222 @@ + + + + + + Image Generator + + + + +
+

Image Generator

+ +
+ +
+ +
+ + + + + + +
+ + + + diff --git a/fastapi-example/main.py b/fastapi-example/main.py new file mode 100644 index 0000000..0418a04 --- /dev/null +++ b/fastapi-example/main.py @@ -0,0 +1,62 @@ +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import logfire +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from httpx import AsyncClient +from openai import AsyncOpenAI +from pydantic import BaseModel, Field +from starlette.responses import FileResponse + +logfire.configure(service_name='fastapi-example') +http_client: AsyncClient +openai_client = AsyncOpenAI() +logfire.instrument_openai(openai_client) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + global http_client, openai_client + async with AsyncClient() as _http_client: + http_client = _http_client + logfire.instrument_httpx(http_client, headers=True) + yield + + +app = FastAPI(lifespan=lifespan) +this_dir = Path(__file__).parent +image_dir = Path(__file__).parent / 'images' +image_dir.mkdir(exist_ok=True) +app.mount('/static', StaticFiles(directory=image_dir), name='static') + + +@app.get('/') +async def main() -> FileResponse: + return FileResponse(this_dir / 'index.html') + + +class GenerateResponse(BaseModel): + image_url: str = Field(serialization_alias='imageUrl') + + +@app.post('/generate') +async def generate_image(prompt: str) -> GenerateResponse: + response = await openai_client.images.generate(prompt=prompt, model='dall-e-3') + + assert response.data, 'No image in response' + + image_url = response.data[0].url + assert image_url, 'No image URL in response' + r = await http_client.get(image_url) + r.raise_for_status() + path = f'{uuid4().hex}.jpg' + (image_dir / path).write_bytes(r.content) + return GenerateResponse(image_url=f'/static/{path}') + + +if __name__ == '__main__': + import uvicorn + + uvicorn.run(app, port=8000) diff --git a/pai-weather/main.py b/pai-weather/main.py index 072021c..cd9bbc0 100644 --- a/pai-weather/main.py +++ b/pai-weather/main.py @@ -42,6 +42,7 @@ async def get_lat_lng(ctx: RunContext[Deps], location_description: str) -> LatLn ctx: The context. location_description: A description of a location. """ + # NOTE: the response here will be random, and is not related to the location description. r = await ctx.deps.client.get( 'https://demo-endpoints.pydantic.workers.dev/latlng', params={'location': location_description, 'sleep': randint(200, 1200)}, @@ -59,6 +60,7 @@ async def get_weather(ctx: RunContext[Deps], lat: float, lng: float) -> dict[str lat: Latitude of the location. lng: Longitude of the location. """ + # NOTE: the responses here will be random, and are not related to the lat and lng. temp_response, descr_response = await asyncio.gather( ctx.deps.client.get( 'https://demo-endpoints.pydantic.workers.dev/number', diff --git a/pyproject.toml b/pyproject.toml index e033c11..8cb7eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "devtools>=0.12.2", - "logfire[httpx]>=3.21.1", + "fastapi>=0.115.14", + "logfire[fastapi,httpx]>=3.21.1", "pydantic-ai>=0.3.4", ] diff --git a/uv.lock b/uv.lock index e4c9abe..149a5dd 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, ] +[[package]] +name = "asgiref" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/68/fb4fb78c9eac59d5e819108a57664737f855c5a8e9b76aec1738bb137f9e/asgiref-3.9.0.tar.gz", hash = "sha256:3dd2556d0f08c4fab8a010d9ab05ef8c34565f6bf32381d17505f7ca5b273767", size = 36772, upload-time = "2025-07-03T13:25:01.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f9/76c9f4d4985b5a642926162e2d41fe6019b1fa929cfa58abb7d2dc9041e5/asgiref-3.9.0-py3-none-any.whl", hash = "sha256:06a41250a0114d2b6f6a2cb3ab962147d355b53d1de15eebc34a9d04a7b79981", size = 23788, upload-time = "2025-07-03T13:24:59.115Z" }, +] + [[package]] name = "asttokens" version = "2.4.1" @@ -250,6 +259,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/20/133fbd3c62829f8b9d0589694201c36175a6a359393d3c1bfd9f8c14994e/fasta2a-0.3.4-py3-none-any.whl", hash = "sha256:8c23ec0fa1e29f58031d32c3ff530e69da298af893f883c94ba973e2ba3ccaa9", size = 15329, upload-time = "2025-06-26T06:25:07.259Z" }, ] +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + [[package]] name = "fastavro" version = "1.11.1" @@ -571,6 +594,9 @@ wheels = [ ] [package.optional-dependencies] +fastapi = [ + { name = "opentelemetry-instrumentation-fastapi" }, +] httpx = [ { name = "opentelemetry-instrumentation-httpx" }, ] @@ -719,6 +745,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/7d/8ddfda1506c2fcca137924d5688ccabffa1aed9ec0955b7d0772de02cec3/opentelemetry_instrumentation-0.55b1-py3-none-any.whl", hash = "sha256:cbb1496b42bc394e01bc63701b10e69094e8564e281de063e4328d122cc7a97e", size = 31108, upload-time = "2025-06-10T08:57:14.355Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4a/900ea42d36757e3b7219f873d3d16358107da43fcb8d7f11a2b1d0bb56a0/opentelemetry_instrumentation_asgi-0.55b1.tar.gz", hash = "sha256:615cde388dd3af4d0e52629a6c75828253618aebcc6e65d93068463811528606", size = 24356, upload-time = "2025-06-10T08:58:19.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/b5f78f0456f8e2e2ec152d7b6496197f5661c7ca49f610fe19c63b350aa4/opentelemetry_instrumentation_asgi-0.55b1-py3-none-any.whl", hash = "sha256:186620f7d0a71c8c817c5cbe91c80faa8f9c50967d458b8131c5694e21eb8583", size = 16402, upload-time = "2025-06-10T08:57:22.034Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/76/0df9cdff4cce18b1967e97152d419e2325c307ff96eb6ba8e69294690c18/opentelemetry_instrumentation_fastapi-0.55b1.tar.gz", hash = "sha256:bb9f8c13a053e7ff7da221248067529cc320e9308d57f3908de0afa36f6c5744", size = 20275, upload-time = "2025-06-10T08:58:29.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/6e/d608a9336ede3d15869c70ebdd4ec670f774641104b0873bb973bce9d822/opentelemetry_instrumentation_fastapi-0.55b1-py3-none-any.whl", hash = "sha256:af4c09aebb0bd6b4a0881483b175e76547d2bc96329c94abfb794bf44f29f6bb", size = 12713, upload-time = "2025-06-10T08:57:39.712Z" }, +] + [[package]] name = "opentelemetry-instrumentation-httpx" version = "0.55b1" @@ -973,14 +1031,16 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "devtools" }, - { name = "logfire", extra = ["httpx"] }, + { name = "fastapi" }, + { name = "logfire", extra = ["fastapi", "httpx"] }, { name = "pydantic-ai" }, ] [package.metadata] requires-dist = [ { name = "devtools", specifier = ">=0.12.2" }, - { name = "logfire", extras = ["httpx"], specifier = ">=3.21.1" }, + { name = "fastapi", specifier = ">=0.115.14" }, + { name = "logfire", extras = ["fastapi", "httpx"], specifier = ">=3.21.1" }, { name = "pydantic-ai", specifier = ">=0.3.4" }, ] @@ -1242,15 +1302,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.1" +version = "0.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/69/662169fdb92fb96ec3eaee218cf540a629d629c86d7993d9651226a6789b/starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b", size = 2583072, upload-time = "2025-06-21T04:03:17.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] [[package]] From 8516f1edcb94c4dc50c4b0a76bd7707ba212ba25 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 4 Jul 2025 10:48:31 +0100 Subject: [PATCH 2/2] improve fastapi example --- fastapi-example/main.py | 12 +++++---- fastapi-example/{index.html => page.html} | 30 ++++++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) rename fastapi-example/{index.html => page.html} (89%) diff --git a/fastapi-example/main.py b/fastapi-example/main.py index 0418a04..20dab85 100644 --- a/fastapi-example/main.py +++ b/fastapi-example/main.py @@ -21,11 +21,12 @@ async def lifespan(_app: FastAPI): global http_client, openai_client async with AsyncClient() as _http_client: http_client = _http_client - logfire.instrument_httpx(http_client, headers=True) + logfire.instrument_httpx(http_client, capture_headers=True) yield app = FastAPI(lifespan=lifespan) +logfire.instrument_fastapi(app, capture_headers=True) this_dir = Path(__file__).parent image_dir = Path(__file__).parent / 'images' image_dir.mkdir(exist_ok=True) @@ -33,12 +34,13 @@ async def lifespan(_app: FastAPI): @app.get('/') +@app.get('/display/{image:path}') async def main() -> FileResponse: - return FileResponse(this_dir / 'index.html') + return FileResponse(this_dir / 'page.html') class GenerateResponse(BaseModel): - image_url: str = Field(serialization_alias='imageUrl') + next_url: str = Field(serialization_alias='nextUrl') @app.post('/generate') @@ -53,10 +55,10 @@ async def generate_image(prompt: str) -> GenerateResponse: r.raise_for_status() path = f'{uuid4().hex}.jpg' (image_dir / path).write_bytes(r.content) - return GenerateResponse(image_url=f'/static/{path}') + return GenerateResponse(next_url=f'/display/{path}') if __name__ == '__main__': import uvicorn - uvicorn.run(app, port=8000) + uvicorn.run(app) diff --git a/fastapi-example/index.html b/fastapi-example/page.html similarity index 89% rename from fastapi-example/index.html rename to fastapi-example/page.html index ef6fbaa..484b411 100644 --- a/fastapi-example/index.html +++ b/fastapi-example/page.html @@ -13,7 +13,7 @@ font-family: "IBM Plex Sans", sans-serif; max-width: 800px; margin: 0 auto; - padding: 80px 20px 20px; + padding: 50px 20px 20px; background-color: #f5f5f5; } @@ -34,6 +34,7 @@ width: 100%; padding: 10px; font-size: 16px; + font-family: inherit; border: 2px solid #ddd; border-radius: 8px; box-sizing: border-box; @@ -45,11 +46,17 @@ border-color: #4caf50; } + a { + text-decoration: none; + } + + a, button { background-color: #4caf50; color: white; padding: 15px 30px; font-size: 16px; + font-family: inherit; border: none; border-radius: 8px; cursor: pointer; @@ -84,6 +91,7 @@ .generated-image { max-width: 100%; + max-height: 600px; height: auto; border-radius: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); @@ -134,7 +142,7 @@

Image Generator

alt="Generated image" />

- + Clear @@ -176,9 +184,9 @@

Image Generator

throw new Error(`HTTP error! status: ${response.status}`); } - const { imageUrl } = await response.json(); + const { nextUrl } = await response.json(); - showImage(imageUrl); + window.location.href = nextUrl; } catch (error) { console.error("Error generating image:", error); showError("Failed to generate image. Please try again."); @@ -207,15 +215,13 @@

Image Generator

promptSection.classList.remove("hidden"); } - function clearImage() { - imageSection.classList.add("hidden"); - errorSection.classList.add("hidden"); - promptSection.classList.remove("hidden"); - promptInput.value = ""; - promptInput.focus(); + const path = window.location.pathname; + const imageMatch = path.match(/^\/display\/(.+\.jpg)$/); - // Clean up the blob URL - URL.revokeObjectURL(generatedImage.src); + if (imageMatch) { + const imageUrl = `/static/${imageMatch[1]}`; + showLoading(); + showImage(imageUrl); }