Skip to content

Commit 00c3af2

Browse files
committed
FastMCP working
1 parent e817d75 commit 00c3af2

File tree

9 files changed

+136
-280
lines changed

9 files changed

+136
-280
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.venv/
22
node_modules/
33
src/vendor/
4-
.vscode/
4+
.vscode/
5+
.wrangler/

README.md

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
1-
# Vendoring Packages: FastAPI + Jinja2 Example
1+
# Python Workers: FastMCP Example
22

3-
*Note: You must have Python Packages enabled on your account for built-in packages to work. Request Access to our Closed Beta using [This Form](https://forms.gle/FcjjhV3YtPyjRPaL8)*
4-
5-
This is an example of a Python Worker that uses a built-in package (FastAPI) with a vendored package (Jinja2).
3+
This is an example of a Python Worker that uses the FastMCP package.
64

75
## Adding Packages
86

9-
Built-in packages can be selected from [this list](https://developers.cloudflare.com/workers/languages/python/packages/#supported-packages) and added to your `requirements.txt` file. These can be used with no other explicit install step.
10-
117
Vendored packages are added to your source files and need to be installed in a special manner. The Python Workers team plans to make this process automatic in the future, but for now, manual steps need to be taken.
128

139
### Vendoring Packages
1410

15-
[//]: # (NOTE: when updating the instructions below, be sure to also update the vendoring.yml CI workflow)
16-
1711
First, install Python3.12 and pip for Python 3.12.
1812

1913
*Currently, other versions of Python will not work - use 3.12!*
@@ -30,34 +24,11 @@ Within our virtual environment, install the pyodide CLI:
3024
.venv/bin/pyodide venv .venv-pyodide
3125
```
3226

33-
Next, add packages to your vendor.txt file. Here we'll add jinja2
34-
```
35-
jinja2
36-
```
37-
38-
Lastly, add these packages to your source files at `src/vendor`. For any additional packages, re-run this command.
27+
Lastly, download the vendored packages. For any additional packages, re-run this command.
3928
```console
4029
.venv-pyodide/bin/pip install -t src/vendor -r vendor.txt
4130
```
4231

43-
### Using Vendored packages
44-
45-
In your wrangler.toml, make the vendor directory available:
46-
47-
```toml
48-
[[rules]]
49-
globs = ["vendor/**"]
50-
type = "Data"
51-
fallthrough = true
52-
```
53-
54-
Now, you can import and use the packages:
55-
56-
```python
57-
import jinja2
58-
# ... etc ...
59-
```
60-
6132
### Developing and Deploying
6233

6334
To develop your Worker, run `npx wrangler@latest dev`.

src/asgi.py

Lines changed: 22 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -1,159 +1,6 @@
1-
from asyncio import Future, Event, Queue, ensure_future, sleep, create_task
1+
from asyncio import Event, Future, Queue, create_task, ensure_future, sleep
22
from contextlib import contextmanager
33
from inspect import isawaitable
4-
import typing
5-
6-
if typing.TYPE_CHECKING:
7-
from typing import (
8-
Any,
9-
Callable,
10-
Literal,
11-
Optional,
12-
Protocol,
13-
TypedDict,
14-
Union,
15-
NotRequired,
16-
)
17-
from collections.abc import Awaitable, Iterable, MutableMapping
18-
19-
class HTTPRequestEvent(TypedDict):
20-
type: Literal["http.request"]
21-
body: bytes
22-
more_body: bool
23-
24-
class HTTPResponseDebugEvent(TypedDict):
25-
type: Literal["http.response.debug"]
26-
info: dict[str, object]
27-
28-
class HTTPResponseStartEvent(TypedDict):
29-
type: Literal["http.response.start"]
30-
status: int
31-
headers: NotRequired[Iterable[tuple[bytes, bytes]]]
32-
trailers: NotRequired[bool]
33-
34-
class HTTPResponseBodyEvent(TypedDict):
35-
type: Literal["http.response.body"]
36-
body: bytes
37-
more_body: NotRequired[bool]
38-
39-
class HTTPResponseTrailersEvent(TypedDict):
40-
type: Literal["http.response.trailers"]
41-
headers: Iterable[tuple[bytes, bytes]]
42-
more_trailers: bool
43-
44-
class HTTPServerPushEvent(TypedDict):
45-
type: Literal["http.response.push"]
46-
path: str
47-
headers: Iterable[tuple[bytes, bytes]]
48-
49-
class HTTPDisconnectEvent(TypedDict):
50-
type: Literal["http.disconnect"]
51-
52-
class WebSocketConnectEvent(TypedDict):
53-
type: Literal["websocket.connect"]
54-
55-
class WebSocketAcceptEvent(TypedDict):
56-
type: Literal["websocket.accept"]
57-
subprotocol: NotRequired[str | None]
58-
headers: NotRequired[Iterable[tuple[bytes, bytes]]]
59-
60-
class _WebSocketReceiveEventBytes(TypedDict):
61-
type: Literal["websocket.receive"]
62-
bytes: bytes
63-
text: NotRequired[None]
64-
65-
class _WebSocketReceiveEventText(TypedDict):
66-
type: Literal["websocket.receive"]
67-
bytes: NotRequired[None]
68-
text: str
69-
70-
WebSocketReceiveEvent = Union[
71-
_WebSocketReceiveEventBytes, _WebSocketReceiveEventText
72-
]
73-
74-
class _WebSocketSendEventBytes(TypedDict):
75-
type: Literal["websocket.send"]
76-
bytes: bytes
77-
text: NotRequired[None]
78-
79-
class _WebSocketSendEventText(TypedDict):
80-
type: Literal["websocket.send"]
81-
bytes: NotRequired[None]
82-
text: str
83-
84-
WebSocketSendEvent = Union[_WebSocketSendEventBytes, _WebSocketSendEventText]
85-
86-
class WebSocketResponseStartEvent(TypedDict):
87-
type: Literal["websocket.http.response.start"]
88-
status: int
89-
headers: Iterable[tuple[bytes, bytes]]
90-
91-
class WebSocketResponseBodyEvent(TypedDict):
92-
type: Literal["websocket.http.response.body"]
93-
body: bytes
94-
more_body: NotRequired[bool]
95-
96-
class WebSocketDisconnectEvent(TypedDict):
97-
type: Literal["websocket.disconnect"]
98-
code: int
99-
reason: NotRequired[str | None]
100-
101-
class WebSocketCloseEvent(TypedDict):
102-
type: Literal["websocket.close"]
103-
code: NotRequired[int]
104-
reason: NotRequired[str | None]
105-
106-
class LifespanStartupEvent(TypedDict):
107-
type: Literal["lifespan.startup"]
108-
109-
class LifespanShutdownEvent(TypedDict):
110-
type: Literal["lifespan.shutdown"]
111-
112-
class LifespanStartupCompleteEvent(TypedDict):
113-
type: Literal["lifespan.startup.complete"]
114-
115-
class LifespanStartupFailedEvent(TypedDict):
116-
type: Literal["lifespan.startup.failed"]
117-
message: str
118-
119-
class LifespanShutdownCompleteEvent(TypedDict):
120-
type: Literal["lifespan.shutdown.complete"]
121-
122-
class LifespanShutdownFailedEvent(TypedDict):
123-
type: Literal["lifespan.shutdown.failed"]
124-
message: str
125-
126-
WebSocketEvent = Union[
127-
WebSocketReceiveEvent, WebSocketDisconnectEvent, WebSocketConnectEvent
128-
]
129-
130-
ASGIReceiveEvent = Union[
131-
HTTPRequestEvent,
132-
HTTPDisconnectEvent,
133-
WebSocketConnectEvent,
134-
WebSocketReceiveEvent,
135-
WebSocketDisconnectEvent,
136-
LifespanStartupEvent,
137-
LifespanShutdownEvent,
138-
]
139-
140-
ASGISendEvent = Union[
141-
HTTPResponseStartEvent,
142-
HTTPResponseBodyEvent,
143-
HTTPResponseTrailersEvent,
144-
HTTPServerPushEvent,
145-
HTTPDisconnectEvent,
146-
WebSocketAcceptEvent,
147-
WebSocketSendEvent,
148-
WebSocketResponseStartEvent,
149-
WebSocketResponseBodyEvent,
150-
WebSocketCloseEvent,
151-
LifespanStartupCompleteEvent,
152-
LifespanStartupFailedEvent,
153-
LifespanShutdownCompleteEvent,
154-
LifespanShutdownFailedEvent,
155-
]
156-
1574

1585
ASGI = {"spec_version": "2.0", "version": "3.0"}
1596

@@ -251,8 +98,9 @@ async def send(got):
25198
return shutdown
25299

253100

254-
async def process_request(app, req, env):
101+
async def process_request(app, req, env, ctx):
255102
from js import Object, Response, TransformStream
103+
256104
from pyodide.ffi import create_proxy
257105

258106
status = None
@@ -274,14 +122,12 @@ async def process_request(app, req, env):
274122
await receive_queue.put({"body": b"", "more_body": False, "type": "http.request"})
275123

276124
async def receive():
277-
print("Receiving")
278125
message = None
279126
if not receive_queue.empty():
280127
message = await receive_queue.get()
281128
else:
282129
await finished_response.wait()
283130
message = {"type": "http.disconnect"}
284-
print(f"Received {message}")
285131
return message
286132

287133
# Create a transform stream for handling streaming responses
@@ -290,12 +136,11 @@ async def receive():
290136
writable = transform_stream.writable
291137
writer = writable.getWriter()
292138

293-
async def send(got: "ASGISendEvent"):
139+
async def send(got):
294140
nonlocal status
295141
nonlocal headers
296142
nonlocal is_sse
297143

298-
print(got)
299144
if got["type"] == "http.response.start":
300145
status = got["status"]
301146
# Like above, we need to convert byte-pairs into string explicitly.
@@ -305,20 +150,18 @@ async def send(got: "ASGISendEvent"):
305150
if k.lower() == "content-type" and v.lower().startswith(
306151
"text/event-stream"
307152
):
308-
print("SSE RESPONSE")
309153
is_sse = True
310-
311-
# For SSE, create and return the response immediately after http.response.start
312-
resp = Response.new(
313-
readable, headers=Object.fromEntries(headers), status=status
314-
)
315-
result.set_result(resp)
316154
break
155+
if is_sse:
156+
# For SSE, create and return the response immediately after http.response.start
157+
resp = Response.new(
158+
readable, headers=Object.fromEntries(headers), status=status
159+
)
160+
result.set_result(resp)
317161

318162
elif got["type"] == "http.response.body":
319163
body = got["body"]
320164
more_body = got.get("more_body", False)
321-
print(f"{body=}, {more_body=}")
322165

323166
# Convert body to JS buffer
324167
px = create_proxy(body)
@@ -337,6 +180,7 @@ async def send(got: "ASGISendEvent"):
337180
buf.data, headers=Object.fromEntries(headers), status=status
338181
)
339182
result.set_result(resp)
183+
await writer.close()
340184
finished_response.set()
341185

342186
# Run the application in the background to handle SSE
@@ -346,17 +190,13 @@ async def run_app():
346190

347191
# If we get here and no response has been set yet, the app didn't generate a response
348192
if not result.done():
349-
await writer.close() # Close the writer
350-
finished_response.set()
351-
result.set_exception(
352-
RuntimeError("The application did not generate a response")
353-
)
193+
raise RuntimeError("The application did not generate a response") # noqa: TRY301
354194
except Exception as e:
355195
# Handle any errors in the application
356196
if not result.done():
197+
result.set_exception(e)
357198
await writer.close() # Close the writer
358199
finished_response.set()
359-
result.set_exception(e)
360200

361201
# Create task to run the application in the background
362202
app_task = create_task(run_app())
@@ -367,7 +207,13 @@ async def run_app():
367207
# For non-SSE responses, we need to wait for the application to complete
368208
if not is_sse:
369209
await app_task
370-
print(f"Returning response! {is_sse}")
210+
else: # noqa: PLR5501
211+
if ctx is not None:
212+
ctx.waitUntil(create_proxy(app_task))
213+
else:
214+
raise RuntimeError(
215+
"Server-Side-Events require ctx to be passed to asgi.fetch"
216+
)
371217
return response
372218

373219

@@ -423,9 +269,9 @@ async def ws_receive():
423269
return Response.new(None, status=101, webSocket=client)
424270

425271

426-
async def fetch(app, req, env):
272+
async def fetch(app, req, env, ctx=None):
427273
shutdown = await start_application(app)
428-
result = await process_request(app, req, env)
274+
result = await process_request(app, req, env, ctx)
429275
await shutdown()
430276
return result
431277

src/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from logger import logger
12
from starlette.exceptions import HTTPException
23
from starlette.requests import Request
34
from starlette.responses import PlainTextResponse, Response
45
async def http_exception(request: Request, exc: Exception) -> Response:
56
assert isinstance(exc, HTTPException)
7+
logger.exception(exc)
68
if exc.status_code in {204, 304}:
79
return Response(status_code=exc.status_code, headers=exc.headers)
810
return PlainTextResponse(exc.detail, status_code=exc.status_code, headers=exc.headers)

0 commit comments

Comments
 (0)