diff --git a/docs/_newsfragments/1820.doc.rst b/docs/_newsfragments/1820.doc.rst new file mode 100644 index 000000000..de84550c2 --- /dev/null +++ b/docs/_newsfragments/1820.doc.rst @@ -0,0 +1 @@ +Added PEP 484 type annotations to WSGI, ASGI, and WebSocket tutorials and sample scripts. diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 42f11627c..79fc853c5 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -149,7 +149,7 @@ Here's how you can set up basic logging in your ASGI Falcon application via class ErrorResource: - def on_get(self, req, resp): + async def on_get(self, req: falcon.asgi.Request, resp: falcon.asgi.Response) -> None: raise Exception('Something went wrong!') @@ -227,7 +227,7 @@ module, ``config.py`` next to ``app.py``, and add the following code to it: DEFAULT_CONFIG_PATH = '/tmp/asgilook' DEFAULT_UUID_GENERATOR = uuid.uuid4 - def __init__(self): + def __init__(self) -> None: self.storage_path = pathlib.Path( os.environ.get('ASGI_LOOK_STORAGE_PATH', self.DEFAULT_CONFIG_PATH)) self.storage_path.mkdir(parents=True, exist_ok=True) @@ -355,20 +355,20 @@ of images. Place the code below in a file named ``images.py``: class Images: - def __init__(self, config, store): + def __init__(self, config: Config, store: Store) -> None: self._config = config self._store = store - async def on_get(self, req, resp): + async def on_get(self, req: falcon.asgi.Request, resp: falcon.asgi.Response) -> None: resp.media = [image.serialize() for image in self._store.list_images()] - async def on_get_image(self, req, resp, image_id): + async def on_get_image(self, req: falcon.asgi.Request, resp: falcon.asgi.Response, image_id: str) -> None: # NOTE: image_id: UUID is converted back to a string identifier. image = self._store.get(str(image_id)) resp.stream = await aiofiles.open(image.path, 'rb') resp.content_type = falcon.MEDIA_JPEG - async def on_post(self, req, resp): + async def on_post(self, req: falcon.asgi.Request, resp: falcon.asgi.Response) -> None: data = await req.stream.read() image_id = str(self._config.uuid_generator()) image = await self._store.save(image_id, data) @@ -465,7 +465,7 @@ Modify ``app.py`` to read as follows: from .store import Store - def create_app(config=None): + def create_app(config: Config | None = None) -> falcon.asgi.App: config = config or Config() store = Store(config) images = Images(config, store) diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index 70327faac..5092b5bac 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -57,7 +57,7 @@ as expected. app = falcon.asgi.App() class HelloWorldResource: - async def on_get(self, req, resp): + async def on_get(self, req: falcon.asgi.Request, resp: falcon.asgi.Response) -> None: resp.media = {'hello': 'world'} app.add_route('/hello', HelloWorldResource()) @@ -105,7 +105,7 @@ let's keep it simple. class EchoWebSocketResource: - async def on_websocket(self, req: Request, ws: WebSocket): + async def on_websocket(self, req: Request, ws: WebSocket) -> None: try: await ws.accept() except WebSocketDisconnected: diff --git a/docs/user/tutorial.rst b/docs/user/tutorial.rst index f6bb0f2b7..7720ff2d0 100644 --- a/docs/user/tutorial.rst +++ b/docs/user/tutorial.rst @@ -236,7 +236,7 @@ and add the following code to it: class Resource: - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: doc = { 'images': [ { @@ -611,11 +611,11 @@ POSTs. Open ``images.py`` and add a POST responder to the _CHUNK_SIZE_BYTES = 4096 # The resource object must now be initialized with a path used during POST - def __init__(self, storage_path): + def __init__(self, storage_path: str) -> None: self._storage_path = storage_path # This is the method we implemented before - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: doc = { 'images': [ { @@ -731,10 +731,10 @@ operation: class Resource: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: doc = { 'images': [ { @@ -747,7 +747,7 @@ operation: resp.content_type = falcon.MEDIA_MSGPACK resp.status = falcon.HTTP_200 - def on_post(self, req, resp): + def on_post(self, req: falcon.Request, resp: falcon.Response) -> None: name = self._image_store.save(req.stream, req.content_type) resp.status = falcon.HTTP_201 resp.location = '/images/' + name @@ -1136,10 +1136,10 @@ Go ahead and edit your ``images.py`` file to look something like this: class Collection: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: # TODO: Modify this to return a list of href's based on # what images are actually available. doc = { @@ -1325,10 +1325,10 @@ terminal-friendly output. The top of file ``images.py`` should look like this: class Collection: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: # TODO: Modify this to return a list of href's based on # what images are actually available. doc = { @@ -1386,10 +1386,10 @@ and also to enable a minimum value validation. class Collection: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: max_size = req.get_param_as_int("maxsize", min_value=1, default=-1) images = self._image_store.list(max_size) doc = { @@ -1409,10 +1409,10 @@ and also to enable a minimum value validation. class Item: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp, name): + def on_get(self, req: falcon.Request, resp: falcon.Response, name: str) -> None: resp.content_type = mimetypes.guess_type(name)[0] resp.stream, resp.content_length = self._image_store.open(name) @@ -1546,7 +1546,7 @@ message. Add this method below the definition of ``ALLOWED_IMAGE_TYPES``: .. code:: python - def validate_image_type(req, resp, resource, params): + def validate_image_type(req: falcon.Request, resp: falcon.Response, resource: object, params: dict[str, Any]) -> None: if req.content_type not in ALLOWED_IMAGE_TYPES: msg = 'Image type not allowed. Must be PNG, JPEG, or GIF' raise falcon.HTTPBadRequest(title='Bad request', description=msg) @@ -1576,7 +1576,7 @@ kwargs: .. code:: python - def extract_project_id(req, resp, resource, params): + def extract_project_id(req: falcon.Request, resp: falcon.Response, resource: object, params: dict[str, Any]) -> None: """Adds `project_id` to the list of params for all responders. Meant to be used as a `before` hook. @@ -1671,10 +1671,10 @@ as follows: class Item: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp, name): + def on_get(self, req: falcon.Request, resp: falcon.Response, name: str) -> None: resp.content_type = mimetypes.guess_type(name)[0] try: diff --git a/examples/asgilook/asgilook/app.py b/examples/asgilook/asgilook/app.py index de3f4e2e6..b045447d4 100644 --- a/examples/asgilook/asgilook/app.py +++ b/examples/asgilook/asgilook/app.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import falcon.asgi from .cache import RedisCache @@ -7,7 +9,7 @@ from .store import Store -def create_app(config=None): +def create_app(config: Config | None = None) -> falcon.asgi.App: config = config or Config() cache = RedisCache(config) store = Store(config) diff --git a/examples/asgilook/asgilook/cache.py b/examples/asgilook/asgilook/cache.py index ebb0f2532..2b49e922c 100644 --- a/examples/asgilook/asgilook/cache.py +++ b/examples/asgilook/asgilook/cache.py @@ -1,5 +1,13 @@ +from __future__ import annotations + +from typing import Any + import msgpack +import falcon.asgi + +from .config import Config + class RedisCache: PREFIX = 'asgilook:' @@ -7,26 +15,32 @@ class RedisCache: CACHE_HEADER = 'X-ASGILook-Cache' TTL = 3600 - def __init__(self, config): + def __init__(self, config: Config) -> None: self._config = config self._redis = self._config.redis_from_url(self._config.redis_host) - async def _serialize_response(self, resp): + async def _serialize_response(self, resp: falcon.asgi.Response) -> bytes: data = await resp.render_body() return msgpack.packb([resp.content_type, data], use_bin_type=True) - def _deserialize_response(self, resp, data): + def _deserialize_response(self, resp: falcon.asgi.Response, data: bytes) -> None: resp.content_type, resp.data = msgpack.unpackb(data, raw=False) resp.complete = True resp.context.cached = True - async def process_startup(self, scope, event): + async def process_startup( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: await self._redis.ping() - async def process_shutdown(self, scope, event): + async def process_shutdown( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: await self._redis.aclose() - async def process_request(self, req, resp): + async def process_request( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: resp.context.cached = False if req.method in self.INVALIDATE_ON: @@ -40,7 +54,13 @@ async def process_request(self, req, resp): else: resp.set_header(self.CACHE_HEADER, 'Miss') - async def process_response(self, req, resp, resource, req_succeeded): + async def process_response( + self, + req: falcon.asgi.Request, + resp: falcon.asgi.Response, + resource: object, + req_succeeded: bool, + ) -> None: if not req_succeeded: return diff --git a/examples/asgilook/asgilook/config.py b/examples/asgilook/asgilook/config.py index 701b4ab78..c8b225f11 100644 --- a/examples/asgilook/asgilook/config.py +++ b/examples/asgilook/asgilook/config.py @@ -12,7 +12,7 @@ class Config: DEFAULT_REDIS_HOST = 'redis://localhost' DEFAULT_UUID_GENERATOR = uuid.uuid4 - def __init__(self): + def __init__(self) -> None: self.storage_path = pathlib.Path( os.environ.get('ASGI_LOOK_STORAGE_PATH', self.DEFAULT_CONFIG_PATH) ) diff --git a/examples/asgilook/asgilook/images.py b/examples/asgilook/asgilook/images.py index 20ce345a0..913df62e5 100644 --- a/examples/asgilook/asgilook/images.py +++ b/examples/asgilook/asgilook/images.py @@ -1,17 +1,26 @@ +from __future__ import annotations + import aiofiles import falcon +from .config import Config +from .store import Store + class Images: - def __init__(self, config, store): + def __init__(self, config: Config, store: Store) -> None: self._config = config self._store = store - async def on_get(self, req, resp): + async def on_get( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: resp.media = [image.serialize() for image in self._store.list_images()] - async def on_get_image(self, req, resp, image_id): + async def on_get_image( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response, image_id: str + ) -> None: # NOTE: image_id: UUID is converted back to a string identifier. image = self._store.get(str(image_id)) if not image: @@ -20,7 +29,9 @@ async def on_get_image(self, req, resp, image_id): resp.stream = await aiofiles.open(image.path, 'rb') resp.content_type = falcon.MEDIA_JPEG - async def on_post(self, req, resp): + async def on_post( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: data = await req.stream.read() image_id = str(self._config.uuid_generator()) image = await self._store.save(image_id, data) @@ -31,10 +42,17 @@ async def on_post(self, req, resp): class Thumbnails: - def __init__(self, store): + def __init__(self, store: Store) -> None: self._store = store - async def on_get(self, req, resp, image_id, width, height): + async def on_get( + self, + req: falcon.asgi.Request, + resp: falcon.asgi.Response, + image_id: str, + width: int, + height: int, + ) -> None: image = self._store.get(str(image_id)) if not image: raise falcon.HTTPNotFound diff --git a/examples/asgilook/asgilook/store.py b/examples/asgilook/asgilook/store.py index f6c43522f..0bf969c33 100644 --- a/examples/asgilook/asgilook/store.py +++ b/examples/asgilook/asgilook/store.py @@ -1,15 +1,21 @@ +from __future__ import annotations + import asyncio import datetime import io +from pathlib import Path +from typing import Any import aiofiles import PIL.Image import falcon +from .config import Config + class Image: - def __init__(self, config, image_id, size): + def __init__(self, config: Config, image_id: str, size: tuple[int, int]) -> None: self._config = config self.image_id = image_id @@ -17,14 +23,14 @@ def __init__(self, config, image_id, size): self.modified = datetime.datetime.now(datetime.timezone.utc) @property - def path(self): - return self._config.storage_path / self.image_id + def path(self) -> Path: + return Path(self._config.storage_path) / self.image_id @property - def uri(self): + def uri(self) -> str: return f'/images/{self.image_id}.jpeg' - def serialize(self): + def serialize(self) -> dict[str, Any]: return { 'id': self.image_id, 'image': self.uri, @@ -33,7 +39,7 @@ def serialize(self): 'thumbnails': self.thumbnails(), } - def thumbnails(self): + def thumbnails(self) -> list[str]: def reductions(size, min_size): width, height = size factor = 2 @@ -48,21 +54,21 @@ def reductions(size, min_size): class Store: - def __init__(self, config): + def __init__(self, config: Config) -> None: self._config = config - self._images = {} + self._images: dict[str, Image] = {} - def _load_from_bytes(self, data): + def _load_from_bytes(self, data: bytes) -> PIL.Image.Image: return PIL.Image.open(io.BytesIO(data)) - def _convert(self, image): + def _convert(self, image: PIL.Image.Image) -> bytes: rgb_image = image.convert('RGB') converted = io.BytesIO() rgb_image.save(converted, 'JPEG') return converted.getvalue() - def _resize(self, data, size): + def _resize(self, data: bytes, size: tuple[int, int]) -> bytes: image = PIL.Image.open(io.BytesIO(data)) image.thumbnail(size) @@ -70,20 +76,20 @@ def _resize(self, data, size): image.save(resized, 'JPEG') return resized.getvalue() - def get(self, image_id): + def get(self, image_id: str) -> Image | None: return self._images.get(image_id) - def list_images(self): + def list_images(self) -> list[Image]: return sorted(self._images.values(), key=lambda item: item.modified) - async def make_thumbnail(self, image, size): + async def make_thumbnail(self, image: Image, size: tuple[int, int]) -> bytes: async with aiofiles.open(image.path, 'rb') as img_file: data = await img_file.read() loop = asyncio.get_running_loop() return await loop.run_in_executor(None, self._resize, data, size) - async def save(self, image_id, data): + async def save(self, image_id: str, data: bytes) -> Image: loop = asyncio.get_running_loop() image = await loop.run_in_executor(None, self._load_from_bytes, data) converted = await loop.run_in_executor(None, self._convert, image) diff --git a/examples/look/look/app.py b/examples/look/look/app.py index 7b591ce40..8fc942e3c 100644 --- a/examples/look/look/app.py +++ b/examples/look/look/app.py @@ -7,14 +7,14 @@ from .images import Item -def create_app(image_store): +def create_app(image_store: ImageStore) -> falcon.App: app = falcon.App() app.add_route('/images', Collection(image_store)) app.add_route('/images/{name}', Item(image_store)) return app -def get_app(): +def get_app() -> falcon.App: storage_path = os.environ.get('LOOK_STORAGE_PATH', '.') image_store = ImageStore(storage_path) return create_app(image_store) diff --git a/examples/look/look/images.py b/examples/look/look/images.py index 61db17f1d..9f3178a6b 100644 --- a/examples/look/look/images.py +++ b/examples/look/look/images.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import io import json import mimetypes @@ -9,10 +11,10 @@ class Collection: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp): + def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: max_size = req.get_param_as_int('maxsize', min_value=1, default=-1) images = self._image_store.list(max_size) doc = {'images': [{'href': '/images/' + image} for image in images]} @@ -20,17 +22,17 @@ def on_get(self, req, resp): resp.text = json.dumps(doc, ensure_ascii=False) resp.status = falcon.HTTP_200 - def on_post(self, req, resp): + def on_post(self, req: falcon.Request, resp: falcon.Response) -> None: name = self._image_store.save(req.stream, req.content_type) resp.status = falcon.HTTP_201 resp.location = '/images/' + name class Item: - def __init__(self, image_store): + def __init__(self, image_store: ImageStore) -> None: self._image_store = image_store - def on_get(self, req, resp, name): + def on_get(self, req: falcon.Request, resp: falcon.Response, name: str) -> None: resp.content_type = mimetypes.guess_type(name)[0] resp.stream, resp.content_length = self._image_store.open(name) @@ -43,12 +45,12 @@ class ImageStore: # Note the use of dependency injection for standard library # methods. We'll use these later to avoid monkey-patching. - def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open): + def __init__(self, storage_path: str, uuidgen=uuid.uuid4, fopen=io.open) -> None: self._storage_path = storage_path self._uuidgen = uuidgen self._fopen = fopen - def save(self, image_stream, image_content_type): + def save(self, image_stream: io.BufferedReader, image_content_type: str) -> str: ext = mimetypes.guess_extension(image_content_type) name = f'{self._uuidgen()}{ext}' image_path = os.path.join(self._storage_path, name) @@ -63,7 +65,7 @@ def save(self, image_stream, image_content_type): return name - def open(self, name): + def open(self, name: str) -> tuple[io.BufferedReader, int]: # Always validate untrusted input! if not self._IMAGE_NAME_PATTERN.match(name): raise OSError('File not found') @@ -74,7 +76,7 @@ def open(self, name): return stream, content_length - def list(self, max_size): + def list(self, max_size: int) -> list[str]: images = [ image for image in os.listdir(self._storage_path) diff --git a/examples/things_advanced.py b/examples/things_advanced.py index 619d0d0cb..af63375a7 100644 --- a/examples/things_advanced.py +++ b/examples/things_advanced.py @@ -1,7 +1,8 @@ -# examples/things_advanced.py +from __future__ import annotations import json import logging +from typing import Any import uuid from wsgiref import simple_server @@ -11,17 +12,22 @@ class StorageEngine: - def get_things(self, marker, limit): + def get_things(self, marker: str, limit: int) -> list[dict[str, Any]]: return [{'id': str(uuid.uuid4()), 'color': 'green'}] - def add_thing(self, thing): + def add_thing(self, thing: dict[str, Any]) -> dict[str, Any]: thing['id'] = str(uuid.uuid4()) return thing class StorageError(Exception): @staticmethod - def handle(req, resp, ex, params): + def handle( + req: falcon.Request, + resp: falcon.Response, + ex: Exception, + params: dict[str, Any], + ) -> None: # TODO: Log the error, clean up, etc. before raising raise falcon.HTTPInternalServerError() @@ -32,7 +38,7 @@ class SinkAdapter: 'y': 'https://search.yahoo.com/search', } - def __call__(self, req, resp, engine): + def __call__(self, req: falcon.Request, resp: falcon.Response, engine: str) -> None: url = self.engines[engine] params = {'q': req.get_param('q', True)} result = requests.get(url, params=params) @@ -43,7 +49,7 @@ def __call__(self, req, resp, engine): class AuthMiddleware: - def process_request(self, req, resp): + def process_request(self, req: falcon.Request, resp: falcon.Response) -> None: token = req.get_header('Authorization') account_id = req.get_header('Account-ID') @@ -72,12 +78,12 @@ def process_request(self, req, resp): href='http://docs.example.com/auth', ) - def _token_is_valid(self, token, account_id): + def _token_is_valid(self, token: str, account_id: str | None) -> bool: return True # Suuuuuure it's valid... class RequireJSON: - def process_request(self, req, resp): + def process_request(self, req: falcon.Request, resp: falcon.Response) -> None: if not req.client_accepts_json: raise falcon.HTTPNotAcceptable( description='This API only supports responses encoded as JSON.', @@ -97,7 +103,7 @@ class JSONTranslator: # this particular use case; this example serves only to illustrate # what is possible. - def process_request(self, req, resp): + def process_request(self, req: falcon.Request, resp: falcon.Response) -> None: # req.stream corresponds to the WSGI wsgi.input environ variable, # and allows you to read bytes from the request body. # @@ -125,15 +131,26 @@ def process_request(self, req, resp): raise falcon.HTTPBadRequest(title='Malformed JSON', description=description) - def process_response(self, req, resp, resource, req_succeeded): + def process_response( + self, + req: falcon.Request, + resp: falcon.Response, + resource: object, + req_succeeded: bool, + ) -> None: if not hasattr(resp.context, 'result'): return resp.text = json.dumps(resp.context.result) -def max_body(limit): - def hook(req, resp, resource, params): +def max_body(limit: int): + def hook( + req: falcon.Request, + resp: falcon.Response, + resource: object, + params: dict[str, Any], + ) -> None: length = req.content_length if length is not None and length > limit: msg = ( @@ -149,11 +166,11 @@ def hook(req, resp, resource, params): class ThingsResource: - def __init__(self, db): + def __init__(self, db: StorageEngine) -> None: self.db = db self.logger = logging.getLogger('thingsapp.' + __name__) - def on_get(self, req, resp, user_id): + def on_get(self, req: falcon.Request, resp: falcon.Response, user_id: str) -> None: marker = req.get_param('marker') or '' limit = req.get_param_as_int('limit') or 50 @@ -182,7 +199,7 @@ def on_get(self, req, resp, user_id): resp.status = falcon.HTTP_200 @falcon.before(max_body(64 * 1024)) - def on_post(self, req, resp, user_id): + def on_post(self, req: falcon.Request, resp: falcon.Response, user_id: str) -> None: try: doc = req.context.doc except AttributeError: diff --git a/examples/things_advanced_asgi.py b/examples/things_advanced_asgi.py index 67f4962f0..0fef8a99a 100644 --- a/examples/things_advanced_asgi.py +++ b/examples/things_advanced_asgi.py @@ -1,7 +1,8 @@ -# examples/things_advanced_asgi.py +from __future__ import annotations import json import logging +from typing import Any import uuid import httpx @@ -11,17 +12,22 @@ class StorageEngine: - async def get_things(self, marker, limit): + async def get_things(self, marker: str, limit: int) -> list[dict[str, Any]]: return [{'id': str(uuid.uuid4()), 'color': 'green'}] - async def add_thing(self, thing): + async def add_thing(self, thing: dict[str, Any]) -> dict[str, Any]: thing['id'] = str(uuid.uuid4()) return thing class StorageError(Exception): @staticmethod - async def handle(req, resp, ex, params): + async def handle( + req: falcon.asgi.Request, + resp: falcon.asgi.Response, + ex: Exception, + params: dict[str, Any], + ) -> None: # TODO: Log the error, clean up, etc. before raising raise falcon.HTTPInternalServerError() @@ -32,20 +38,24 @@ class SinkAdapter: 'y': 'https://search.yahoo.com/search', } - async def __call__(self, req, resp, engine): + async def __call__( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response, engine: str + ) -> None: url = self.engines[engine] params = {'q': req.get_param('q', True)} async with httpx.AsyncClient() as client: result = await client.get(url, params=params) - resp.status = result.status_code + resp.status = falcon.code_to_http_status(result.status_code) resp.content_type = result.headers['content-type'] resp.text = result.text class AuthMiddleware: - async def process_request(self, req, resp): + async def process_request( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: token = req.get_header('Authorization') account_id = req.get_header('Account-ID') @@ -74,12 +84,14 @@ async def process_request(self, req, resp): href='http://docs.example.com/auth', ) - def _token_is_valid(self, token, account_id): + def _token_is_valid(self, token: str, account_id: str | None) -> bool: return True # Suuuuuure it's valid... class RequireJSON: - async def process_request(self, req, resp): + async def process_request( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: if not req.client_accepts_json: raise falcon.HTTPNotAcceptable( description='This API only supports responses encoded as JSON.', @@ -95,20 +107,22 @@ async def process_request(self, req, resp): class JSONTranslator: - # NOTE: Normally you would simply use req.get_media() and resp.media for + # NOTE: Normally you would simply use req.media and resp.media for # this particular use case; this example serves only to illustrate # what is possible. - async def process_request(self, req, resp): - # NOTE: Test explicitly for 0, since this property could be None in - # the case that the Content-Length header is missing (in which case we - # can't know if there is a body without actually attempting to read - # it from the request stream.) - if req.content_length == 0: + async def process_request( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response + ) -> None: + # req.stream corresponds to the WSGI wsgi.input environ variable, + # and allows you to read bytes from the request body. + # + # See also: PEP 3333 + if req.content_length in (None, 0): # Nothing to do return - body = await req.stream.read() + body = await req.bounded_stream.read() if not body: raise falcon.HTTPBadRequest( title='Empty request body', @@ -127,15 +141,26 @@ async def process_request(self, req, resp): raise falcon.HTTPBadRequest(title='Malformed JSON', description=description) - async def process_response(self, req, resp, resource, req_succeeded): + async def process_response( + self, + req: falcon.asgi.Request, + resp: falcon.asgi.Response, + resource: object, + req_succeeded: bool, + ) -> None: if not hasattr(resp.context, 'result'): return resp.text = json.dumps(resp.context.result) -def max_body(limit): - async def hook(req, resp, resource, params): +def max_body(limit: int): + async def hook( + req: falcon.asgi.Request, + resp: falcon.asgi.Response, + resource: object, + params: dict[str, Any], + ) -> None: length = req.content_length if length is not None and length > limit: msg = ( @@ -151,11 +176,13 @@ async def hook(req, resp, resource, params): class ThingsResource: - def __init__(self, db): + def __init__(self, db: StorageEngine) -> None: self.db = db self.logger = logging.getLogger('thingsapp.' + __name__) - async def on_get(self, req, resp, user_id): + async def on_get( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response, user_id: str + ) -> None: marker = req.get_param('marker') or '' limit = req.get_param_as_int('limit') or 50 @@ -184,7 +211,9 @@ async def on_get(self, req, resp, user_id): resp.status = falcon.HTTP_200 @falcon.before(max_body(64 * 1024)) - async def on_post(self, req, resp, user_id): + async def on_post( + self, req: falcon.asgi.Request, resp: falcon.asgi.Response, user_id: str + ) -> None: try: doc = req.context.doc except AttributeError: diff --git a/examples/ws_tutorial/ws_tutorial/app.py b/examples/ws_tutorial/ws_tutorial/app.py index 054514a61..6b5aa77ab 100644 --- a/examples/ws_tutorial/ws_tutorial/app.py +++ b/examples/ws_tutorial/ws_tutorial/app.py @@ -1,12 +1,14 @@ from datetime import datetime import logging import pathlib +from typing import Any import uvicorn from falcon import WebSocketDisconnected import falcon.asgi from falcon.asgi import Request +from falcon.asgi import Response from falcon.asgi import WebSocket logger = logging.getLogger('ws-logger') @@ -37,12 +39,14 @@ class LoggerMiddleware: - async def process_request_ws(self, req: Request, ws: WebSocket): + async def process_request_ws(self, req: Request, ws: WebSocket) -> None: # This will be called for the HTTP request that initiates the # WebSocket handshake before routing. pass - async def process_resource_ws(self, req: Request, ws: WebSocket, resource, params): + async def process_resource_ws( + self, req: Request, ws: WebSocket, resource: object, params: dict[str, Any] + ) -> None: # This will be called for the HTTP request that initiates the # WebSocket handshake after routing (if a route matches the # request). @@ -56,7 +60,7 @@ def __init__(self, protected_routes: list[str] | None = None): self.protected_routes = protected_routes - async def process_request_ws(self, req: Request, ws: WebSocket): + async def process_request_ws(self, req: Request, ws: WebSocket) -> None: # Opening a connection so we can receive the token await ws.accept() @@ -75,12 +79,12 @@ async def process_request_ws(self, req: Request, ws: WebSocket): class HelloWorldResource: - async def on_get(self, req, resp): + async def on_get(self, req: Request, resp: Response) -> None: resp.media = {'hello': 'world'} class EchoWebSocketResource: - async def on_websocket(self, req: Request, ws: WebSocket): + async def on_websocket(self, req: Request, ws: WebSocket) -> None: while True: try: message = await ws.receive_text() @@ -92,7 +96,7 @@ async def on_websocket(self, req: Request, ws: WebSocket): class ReportsResource: - async def on_websocket(self, req: Request, ws: WebSocket): + async def on_websocket(self, req: Request, ws: WebSocket) -> None: while True: try: query = await ws.receive_text()