Skip to content

Commit 6d58f2f

Browse files
authored
Merge pull request #3 from pydantic/fastapi
Add FastAPI example
2 parents c1f1af8 + 8516f1e commit 6d58f2f

File tree

6 files changed

+363
-7
lines changed

6 files changed

+363
-7
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ wheels/
99
# Virtual environments
1010
.venv
1111
*.svg
12+
13+
/fastapi-example/images/

fastapi-example/main.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from contextlib import asynccontextmanager
2+
from pathlib import Path
3+
from uuid import uuid4
4+
5+
import logfire
6+
from fastapi import FastAPI
7+
from fastapi.staticfiles import StaticFiles
8+
from httpx import AsyncClient
9+
from openai import AsyncOpenAI
10+
from pydantic import BaseModel, Field
11+
from starlette.responses import FileResponse
12+
13+
logfire.configure(service_name='fastapi-example')
14+
http_client: AsyncClient
15+
openai_client = AsyncOpenAI()
16+
logfire.instrument_openai(openai_client)
17+
18+
19+
@asynccontextmanager
20+
async def lifespan(_app: FastAPI):
21+
global http_client, openai_client
22+
async with AsyncClient() as _http_client:
23+
http_client = _http_client
24+
logfire.instrument_httpx(http_client, capture_headers=True)
25+
yield
26+
27+
28+
app = FastAPI(lifespan=lifespan)
29+
logfire.instrument_fastapi(app, capture_headers=True)
30+
this_dir = Path(__file__).parent
31+
image_dir = Path(__file__).parent / 'images'
32+
image_dir.mkdir(exist_ok=True)
33+
app.mount('/static', StaticFiles(directory=image_dir), name='static')
34+
35+
36+
@app.get('/')
37+
@app.get('/display/{image:path}')
38+
async def main() -> FileResponse:
39+
return FileResponse(this_dir / 'page.html')
40+
41+
42+
class GenerateResponse(BaseModel):
43+
next_url: str = Field(serialization_alias='nextUrl')
44+
45+
46+
@app.post('/generate')
47+
async def generate_image(prompt: str) -> GenerateResponse:
48+
response = await openai_client.images.generate(prompt=prompt, model='dall-e-3')
49+
50+
assert response.data, 'No image in response'
51+
52+
image_url = response.data[0].url
53+
assert image_url, 'No image URL in response'
54+
r = await http_client.get(image_url)
55+
r.raise_for_status()
56+
path = f'{uuid4().hex}.jpg'
57+
(image_dir / path).write_bytes(r.content)
58+
return GenerateResponse(next_url=f'/display/{path}')
59+
60+
61+
if __name__ == '__main__':
62+
import uvicorn
63+
64+
uvicorn.run(app)

fastapi-example/page.html

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Image Generator</title>
7+
<link
8+
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&display=swap"
9+
rel="stylesheet"
10+
/>
11+
<style>
12+
body {
13+
font-family: "IBM Plex Sans", sans-serif;
14+
max-width: 800px;
15+
margin: 0 auto;
16+
padding: 50px 20px 20px;
17+
background-color: #f5f5f5;
18+
}
19+
20+
.container {
21+
text-align: center;
22+
}
23+
24+
h1 {
25+
color: #333;
26+
margin-bottom: 30px;
27+
}
28+
29+
.prompt-section {
30+
margin-bottom: 20px;
31+
}
32+
33+
input[type="text"] {
34+
width: 100%;
35+
padding: 10px;
36+
font-size: 16px;
37+
font-family: inherit;
38+
border: 2px solid #ddd;
39+
border-radius: 8px;
40+
box-sizing: border-box;
41+
margin-bottom: 15px;
42+
}
43+
44+
input[type="text"]:focus {
45+
outline: none;
46+
border-color: #4caf50;
47+
}
48+
49+
a {
50+
text-decoration: none;
51+
}
52+
53+
a,
54+
button {
55+
background-color: #4caf50;
56+
color: white;
57+
padding: 15px 30px;
58+
font-size: 16px;
59+
font-family: inherit;
60+
border: none;
61+
border-radius: 8px;
62+
cursor: pointer;
63+
margin: 5px;
64+
}
65+
66+
button:hover {
67+
background-color: #45a049;
68+
}
69+
70+
button:disabled {
71+
background-color: #cccccc;
72+
cursor: not-allowed;
73+
}
74+
75+
.clear-btn {
76+
background-color: #f44336;
77+
}
78+
79+
.clear-btn:hover {
80+
background-color: #da190b;
81+
}
82+
83+
.loading {
84+
margin: 20px 0;
85+
color: #666;
86+
}
87+
88+
.image-container {
89+
margin-top: 20px;
90+
}
91+
92+
.generated-image {
93+
max-width: 100%;
94+
max-height: 600px;
95+
height: auto;
96+
border-radius: 10px;
97+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
98+
}
99+
100+
.error {
101+
color: #f44336;
102+
margin: 20px 0;
103+
padding: 15px;
104+
background-color: #ffebee;
105+
border-radius: 5px;
106+
}
107+
108+
.hidden {
109+
display: none;
110+
}
111+
</style>
112+
</head>
113+
<body>
114+
<div class="container">
115+
<h1>Image Generator</h1>
116+
117+
<div id="promptSection" class="prompt-section">
118+
<input
119+
type="text"
120+
id="promptInput"
121+
placeholder="Painting of an iphone in the style of Titian..."
122+
maxlength="500"
123+
/>
124+
<br />
125+
<button id="generateBtn" onclick="generateImage()">
126+
Generate Image
127+
</button>
128+
</div>
129+
130+
<div id="loadingSection" class="loading hidden">
131+
<p>Generating your image...</p>
132+
</div>
133+
134+
<div id="errorSection" class="error hidden">
135+
<p id="errorMessage"></p>
136+
</div>
137+
138+
<div id="imageSection" class="image-container hidden">
139+
<img
140+
id="generatedImage"
141+
class="generated-image"
142+
alt="Generated image"
143+
/>
144+
<br /><br />
145+
<a href="/" class="clear-btn" onclick="clearImage()">Clear</a>
146+
</div>
147+
</div>
148+
149+
<script>
150+
const promptInput = document.getElementById("promptInput");
151+
const generateBtn = document.getElementById("generateBtn");
152+
const promptSection = document.getElementById("promptSection");
153+
const loadingSection = document.getElementById("loadingSection");
154+
const errorSection = document.getElementById("errorSection");
155+
const imageSection = document.getElementById("imageSection");
156+
const generatedImage = document.getElementById("generatedImage");
157+
const errorMessage = document.getElementById("errorMessage");
158+
159+
promptInput.addEventListener("keypress", function (e) {
160+
if (e.key === "Enter") {
161+
generateImage();
162+
}
163+
});
164+
165+
async function generateImage() {
166+
const prompt = promptInput.value.trim();
167+
168+
if (!prompt) {
169+
showError("Please enter a description for the image.");
170+
return;
171+
}
172+
173+
showLoading();
174+
175+
const params = new URLSearchParams();
176+
params.append("prompt", prompt);
177+
178+
try {
179+
const response = await fetch(`/generate?${params.toString()}`, {
180+
method: "POST",
181+
});
182+
183+
if (!response.ok) {
184+
throw new Error(`HTTP error! status: ${response.status}`);
185+
}
186+
187+
const { nextUrl } = await response.json();
188+
189+
window.location.href = nextUrl;
190+
} catch (error) {
191+
console.error("Error generating image:", error);
192+
showError("Failed to generate image. Please try again.");
193+
}
194+
}
195+
196+
function showLoading() {
197+
promptSection.classList.add("hidden");
198+
errorSection.classList.add("hidden");
199+
imageSection.classList.add("hidden");
200+
loadingSection.classList.remove("hidden");
201+
}
202+
203+
function showImage(imageUrl) {
204+
loadingSection.classList.add("hidden");
205+
errorSection.classList.add("hidden");
206+
generatedImage.src = imageUrl;
207+
imageSection.classList.remove("hidden");
208+
}
209+
210+
function showError(message) {
211+
loadingSection.classList.add("hidden");
212+
imageSection.classList.add("hidden");
213+
errorMessage.textContent = message;
214+
errorSection.classList.remove("hidden");
215+
promptSection.classList.remove("hidden");
216+
}
217+
218+
const path = window.location.pathname;
219+
const imageMatch = path.match(/^\/display\/(.+\.jpg)$/);
220+
221+
if (imageMatch) {
222+
const imageUrl = `/static/${imageMatch[1]}`;
223+
showLoading();
224+
showImage(imageUrl);
225+
}
226+
</script>
227+
</body>
228+
</html>

pai-weather/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ async def get_lat_lng(ctx: RunContext[Deps], location_description: str) -> LatLn
4242
ctx: The context.
4343
location_description: A description of a location.
4444
"""
45+
# NOTE: the response here will be random, and is not related to the location description.
4546
r = await ctx.deps.client.get(
4647
'https://demo-endpoints.pydantic.workers.dev/latlng',
4748
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
5960
lat: Latitude of the location.
6061
lng: Longitude of the location.
6162
"""
63+
# NOTE: the responses here will be random, and are not related to the lat and lng.
6264
temp_response, descr_response = await asyncio.gather(
6365
ctx.deps.client.get(
6466
'https://demo-endpoints.pydantic.workers.dev/number',

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"devtools>=0.12.2",
9-
"logfire[httpx]>=3.21.1",
9+
"fastapi>=0.115.14",
10+
"logfire[fastapi,httpx]>=3.21.1",
1011
"pydantic-ai>=0.3.4",
1112
]
1213

0 commit comments

Comments
 (0)