diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index 48804d551..2e0658e0d 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -56,11 +56,8 @@ Next, :ref:`install Falcon ` into your *virtualenv*:: You can then create a basic :class:`Falcon ASGI application ` by adding an ``asgilook/app.py`` module with the following contents: -.. code:: python - - import falcon.asgi - - app = falcon.asgi.App() +.. literalinclude:: ../../examples/asgilook/basic_falcon.py + :language: python As in the :ref:`WSGI tutorial's introduction `, let's not forget to mark ``asgilook`` as a Python package: @@ -134,22 +131,8 @@ might interfere with the user's logging setup. Here's how you can set up basic logging in your ASGI Falcon application via :func:`logging.basicConfig`: -.. code:: python - - import logging - - import falcon - - logging.basicConfig(level=logging.INFO) - - - class ErrorResource: - def on_get(self, req, resp): - raise Exception('Something went wrong!') - - - app = falcon.App() - app.add_route('/error', ErrorResource()) +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/logging.py + :language: python When the above route is accessed, Falcon will catch the unhandled exception and automatically log an error message. Below is an example of what the log output @@ -194,23 +177,8 @@ In this tutorial, we'll just pass around a ``Config`` instance to resource initializers for easier testing (coming later in this tutorial). Create a new module, ``config.py`` next to ``app.py``, and add the following code to it: -.. code:: python - - import os - import pathlib - import uuid - - - class Config: - DEFAULT_CONFIG_PATH = '/tmp/asgilook' - DEFAULT_UUID_GENERATOR = uuid.uuid4 - - def __init__(self): - 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) - - self.uuid_generator = Config.DEFAULT_UUID_GENERATOR +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/config.py + :language: python Image Store ----------- @@ -229,76 +197,8 @@ all uploaded images to JPEG with the popular We can now implement a basic async image store. Save the following code as ``store.py`` next to ``app.py`` and ``config.py``: -.. code:: python - - import asyncio - import datetime - import io - - import aiofiles - import PIL.Image - - import falcon - - - class Image: - def __init__(self, config, image_id, size): - self._config = config - - self.image_id = image_id - self.size = size - self.modified = datetime.datetime.now(datetime.timezone.utc) - - @property - def path(self): - return self._config.storage_path / self.image_id - - @property - def uri(self): - return f'/images/{self.image_id}.jpeg' - - def serialize(self): - return { - 'id': self.image_id, - 'image': self.uri, - 'modified': falcon.dt_to_http(self.modified), - 'size': self.size, - } - - - class Store: - def __init__(self, config): - self._config = config - self._images = {} - - def _load_from_bytes(self, data): - return PIL.Image.open(io.BytesIO(data)) - - def _convert(self, image): - rgb_image = image.convert('RGB') - - converted = io.BytesIO() - rgb_image.save(converted, 'JPEG') - return converted.getvalue() - - def get(self, image_id): - return self._images.get(image_id) - - def list_images(self): - return sorted(self._images.values(), key=lambda item: item.modified) - - async def save(self, image_id, data): - 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) - - path = self._config.storage_path / image_id - async with aiofiles.open(path, 'wb') as output: - await output.write(converted) - - stored = Image(self._config, image_id, image.size) - self._images[image_id] = stored - return stored +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/store.py + :language: python Here we store data using ``aiofiles``, and run ``Pillow`` image transformation functions in the default :class:`~concurrent.futures.ThreadPoolExecutor`, @@ -325,35 +225,8 @@ methods must be awaitable coroutines. Let's see how this works by implementing a resource to represent both a single image and a collection of images. Place the code below in a file named ``images.py``: -.. code:: python - - import aiofiles - - import falcon - - - class Images: - def __init__(self, config, store): - self._config = config - self._store = store - - async def on_get(self, req, resp): - resp.media = [image.serialize() for image in self._store.list_images()] - - async def on_get_image(self, req, resp, image_id): - # 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): - data = await req.stream.read() - image_id = str(self._config.uuid_generator()) - image = await self._store.save(image_id, data) - - resp.location = image.uri - resp.media = image.serialize() - resp.status = falcon.HTTP_201 +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/images.py + :language: python This module is an example of a Falcon "resource" class, as described in :ref:`routing`. Falcon uses resource-based routing to encourage a RESTful @@ -434,25 +307,8 @@ test cases. Modify ``app.py`` to read as follows: -.. code:: python - - import falcon.asgi - - from .config import Config - from .images import Images - from .store import Store - - - def create_app(config=None): - config = config or Config() - store = Store(config) - images = Images(config, store) - - app = falcon.asgi.App() - app.add_route('/images', images) - app.add_route('/images/{image_id:uuid}.jpeg', images, suffix='image') - - return app +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/app.py + :language: python As mentioned earlier, we need to use a route suffix for the ``Images`` class to distinguish between a GET for a single image vs. the entire collection of @@ -469,7 +325,7 @@ section. In order to bootstrap an ASGI app instance for ``uvicorn`` to reference, we'll create a simple ``asgi.py`` module with the following contents: -.. literalinclude:: ../../examples/asgilook/asgilook/asgi.py +.. literalinclude:: ../../examples/asgilook/01_basic/asgilook/asgi.py :language: python Running the application is not too dissimilar from the previous command line:: @@ -560,45 +416,29 @@ purposes. Let's add a new method ``Store.make_thumbnail()`` to perform scaling on the fly: -.. code:: python - - async def make_thumbnail(self, image, size): - 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) +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/store.py + :start-at: async def make_thumbnail(self, image, size): + :end-at: return await loop.run_in_executor(None, self._resize, data, size) + :language: python + :dedent: 4 We'll also add an internal helper to run the ``Pillow`` thumbnail operation that is offloaded to a threadpool executor, again, in hoping that Pillow can release the GIL for some operations: -.. code:: python - - def _resize(self, data, size): - image = PIL.Image.open(io.BytesIO(data)) - image.thumbnail(size) - - resized = io.BytesIO() - image.save(resized, 'JPEG') - return resized.getvalue() +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/store.py + :start-at: def _resize(self, data, size): + :end-at: return resized.getvalue() + :language: python + :dedent: 4 The ``store.Image`` class can be extended to also return URIs to thumbnails: -.. code:: python - - def thumbnails(self): - def reductions(size, min_size): - width, height = size - factor = 2 - while width // factor >= min_size and height // factor >= min_size: - yield (width // factor, height // factor) - factor *= 2 - - return [ - f'/thumbnails/{self.image_id}/{width}x{height}.jpeg' - for width, height in reductions( - self.size, self._config.min_thumb_size)] +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/store.py + :start-at: def thumbnails(self): + :end-before: class Store: + :language: python + :dedent: 4 Here, we only generate URIs for a series of downsized resolutions. The actual scaling will happen on the fly upon requesting these resources. @@ -610,7 +450,7 @@ You may wish to experiment with this resolution distribution. After updating ``store.py``, the module should now look like this: -.. literalinclude:: ../../examples/asgilook/asgilook/store.py +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/store.py :language: python Furthermore, it is practical to impose a minimum resolution, as any potential @@ -622,30 +462,13 @@ The :ref:`app configuration ` will need to be updated to add the ``min_thumb_size`` option (by default initialized to 64 pixels) as follows: -.. code:: python - - import os - import pathlib - import uuid - - - class Config: - DEFAULT_CONFIG_PATH = '/tmp/asgilook' - DEFAULT_MIN_THUMB_SIZE = 64 - DEFAULT_UUID_GENERATOR = uuid.uuid4 - - def __init__(self): - 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) - - self.uuid_generator = Config.DEFAULT_UUID_GENERATOR - self.min_thumb_size = self.DEFAULT_MIN_THUMB_SIZE +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/config.py + :language: python Let's also add a ``Thumbnails`` resource to expose the new functionality. The final version of ``images.py`` reads: -.. literalinclude:: ../../examples/asgilook/asgilook/images.py +.. literalinclude:: ../../examples/asgilook/02_dynamic_thumbnails/asgilook/images.py :language: python .. note:: @@ -780,13 +603,11 @@ Let's implement the ``process_startup()`` and ``process_shutdown()`` handlers in our middleware to execute code upon our application's startup and shutdown, respectively: -.. code:: python - - async def process_startup(self, scope, event): - await self._redis.ping() - - async def process_shutdown(self, scope, event): - await self._redis.close() +.. literalinclude:: ../../examples/asgilook/03_caching/asgilook/cache.py + :start-at: async def process_startup(self, scope, event): + :end-at: await self._redis.close() + :language: python + :dedent: 4 .. warning:: The Lifespan Protocol is an optional extension; please check if your ASGI @@ -808,7 +629,7 @@ implementations for production and testing. Assuming we call our new :ref:`configuration ` item ``redis_host`` the final version of ``config.py`` now reads: -.. literalinclude:: ../../examples/asgilook/asgilook/config.py +.. literalinclude:: ../../examples/asgilook/03_caching/asgilook/config.py :language: python Let's complete the Redis cache component by implementing @@ -816,14 +637,14 @@ two more middleware methods, in addition to ``process_startup()`` and ``process_shutdown()``. Create a ``cache.py`` module containing the following code: -.. literalinclude:: ../../examples/asgilook/asgilook/cache.py +.. literalinclude:: ../../examples/asgilook/03_caching/asgilook/cache.py :language: python For caching to take effect, we also need to modify ``app.py`` to add the ``RedisCache`` component to our application's middleware list. The final version of ``app.py`` should look something like this: -.. literalinclude:: ../../examples/asgilook/asgilook/app.py +.. literalinclude:: ../../examples/asgilook/03_caching/asgilook/app.py :language: python Now, subsequent access to ``/thumbnails`` should be cached, as indicated by the @@ -921,7 +742,7 @@ Next, let's implement fixtures to replace ``uuid`` and ``redis``, and inject the into our tests via ``conftest.py`` (place your code in the newly created ``tests`` directory): -.. literalinclude:: ../../examples/asgilook/tests/conftest.py +.. literalinclude:: ../../examples/asgilook/asgilook_tests/tests/conftest.py :language: python .. note:: @@ -948,13 +769,8 @@ With the groundwork in place, we can start with a simple test that will attempt to GET the ``/images`` resource. Place the following code in a new ``tests/test_images.py`` module: -.. code:: python - - def test_list_images(client): - resp = client.simulate_get('/images') - - assert resp.status_code == 200 - assert resp.json == [] +.. literalinclude:: ../../examples/asgilook/asgilook_tests/tests/test_images.py + :language: python Let's give it a try:: diff --git a/examples/asgilook/.coveragerc b/examples/asgilook/.coveragerc deleted file mode 100644 index 3b95cad0f..000000000 --- a/examples/asgilook/.coveragerc +++ /dev/null @@ -1,3 +0,0 @@ -[run] -omit = - examples/asgilook/asgilook/asgi.py diff --git a/examples/asgilook/asgilook/__init__.py b/examples/asgilook/01_basic/asgilook/__init__.py similarity index 100% rename from examples/asgilook/asgilook/__init__.py rename to examples/asgilook/01_basic/asgilook/__init__.py diff --git a/examples/asgilook/01_basic/asgilook/app.py b/examples/asgilook/01_basic/asgilook/app.py new file mode 100644 index 000000000..1929c386b --- /dev/null +++ b/examples/asgilook/01_basic/asgilook/app.py @@ -0,0 +1,17 @@ +import falcon.asgi + +from .config import Config +from .images import Images +from .store import Store + + +def create_app(config=None): + config = config or Config() + store = Store(config) + images = Images(config, store) + + app = falcon.asgi.App() + app.add_route('/images', images) + app.add_route('/images/{image_id:uuid}.jpeg', images, suffix='image') + + return app diff --git a/examples/asgilook/asgilook/asgi.py b/examples/asgilook/01_basic/asgilook/asgi.py similarity index 100% rename from examples/asgilook/asgilook/asgi.py rename to examples/asgilook/01_basic/asgilook/asgi.py diff --git a/examples/asgilook/01_basic/asgilook/config.py b/examples/asgilook/01_basic/asgilook/config.py new file mode 100644 index 000000000..493619510 --- /dev/null +++ b/examples/asgilook/01_basic/asgilook/config.py @@ -0,0 +1,16 @@ +import os +import pathlib +import uuid + + +class Config: + DEFAULT_CONFIG_PATH = '/tmp/asgilook' + DEFAULT_UUID_GENERATOR = uuid.uuid4 + + def __init__(self): + 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) + + self.uuid_generator = Config.DEFAULT_UUID_GENERATOR diff --git a/examples/asgilook/01_basic/asgilook/images.py b/examples/asgilook/01_basic/asgilook/images.py new file mode 100644 index 000000000..d8614d325 --- /dev/null +++ b/examples/asgilook/01_basic/asgilook/images.py @@ -0,0 +1,27 @@ +import aiofiles + +import falcon + + +class Images: + def __init__(self, config, store): + self._config = config + self._store = store + + async def on_get(self, req, resp): + resp.media = [image.serialize() for image in self._store.list_images()] + + async def on_get_image(self, req, resp, image_id): + # 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): + data = await req.stream.read() + image_id = str(self._config.uuid_generator()) + image = await self._store.save(image_id, data) + + resp.location = image.uri + resp.media = image.serialize() + resp.status = falcon.HTTP_201 diff --git a/examples/asgilook/01_basic/asgilook/logging.py b/examples/asgilook/01_basic/asgilook/logging.py new file mode 100644 index 000000000..22ccfc20a --- /dev/null +++ b/examples/asgilook/01_basic/asgilook/logging.py @@ -0,0 +1,14 @@ +import logging + +import falcon.asgi + +logging.basicConfig(level=logging.INFO) + + +class ErrorResource: + async def on_get(self, req, resp): + raise Exception('Something went wrong!') + + +app = falcon.asgi.App() +app.add_route('/error', ErrorResource()) diff --git a/examples/asgilook/01_basic/asgilook/store.py b/examples/asgilook/01_basic/asgilook/store.py new file mode 100644 index 000000000..702877713 --- /dev/null +++ b/examples/asgilook/01_basic/asgilook/store.py @@ -0,0 +1,68 @@ +import asyncio +import datetime +import io + +import aiofiles +import PIL.Image + +import falcon + + +class Image: + def __init__(self, config, image_id, size): + self._config = config + + self.image_id = image_id + self.size = size + self.modified = datetime.datetime.now(datetime.timezone.utc) + + @property + def path(self): + return self._config.storage_path / self.image_id + + @property + def uri(self): + return f'/images/{self.image_id}.jpeg' + + def serialize(self): + return { + 'id': self.image_id, + 'image': self.uri, + 'modified': falcon.dt_to_http(self.modified), + 'size': self.size, + } + + +class Store: + def __init__(self, config): + self._config = config + self._images = {} + + def _load_from_bytes(self, data): + return PIL.Image.open(io.BytesIO(data)) + + def _convert(self, image): + rgb_image = image.convert('RGB') + + converted = io.BytesIO() + rgb_image.save(converted, 'JPEG') + return converted.getvalue() + + def get(self, image_id): + return self._images.get(image_id) + + def list_images(self): + return sorted(self._images.values(), key=lambda item: item.modified) + + async def save(self, image_id, data): + 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) + + path = self._config.storage_path / image_id + async with aiofiles.open(path, 'wb') as output: + await output.write(converted) + + stored = Image(self._config, image_id, image.size) + self._images[image_id] = stored + return stored diff --git a/examples/asgilook/01_basic/requirements b/examples/asgilook/01_basic/requirements new file mode 100644 index 000000000..6e5f119f1 --- /dev/null +++ b/examples/asgilook/01_basic/requirements @@ -0,0 +1,5 @@ +falcon +uvicorn +httpie +aiofiles +Pillow \ No newline at end of file diff --git a/examples/asgilook/tests/__init__.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/__init__.py similarity index 100% rename from examples/asgilook/tests/__init__.py rename to examples/asgilook/02_dynamic_thumbnails/asgilook/__init__.py diff --git a/examples/asgilook/02_dynamic_thumbnails/asgilook/app.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/app.py new file mode 100644 index 000000000..bbd1b2354 --- /dev/null +++ b/examples/asgilook/02_dynamic_thumbnails/asgilook/app.py @@ -0,0 +1,22 @@ +import falcon.asgi + +from .config import Config +from .images import Images +from .images import Thumbnails +from .store import Store + + +def create_app(config=None): + config = config or Config() + store = Store(config) + images = Images(config, store) + thumbnails = Thumbnails(store) + + app = falcon.asgi.App() + app.add_route('/images', images) + app.add_route('/images/{image_id:uuid}.jpeg', images, suffix='image') + app.add_route( + '/thumbnails/{image_id:uuid}/{width:int}x{height:int}.jpeg', thumbnails + ) + + return app diff --git a/examples/asgilook/02_dynamic_thumbnails/asgilook/asgi.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/asgi.py new file mode 100644 index 000000000..793f52dc2 --- /dev/null +++ b/examples/asgilook/02_dynamic_thumbnails/asgilook/asgi.py @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/examples/asgilook/02_dynamic_thumbnails/asgilook/config.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/config.py new file mode 100644 index 000000000..3d3d8bb73 --- /dev/null +++ b/examples/asgilook/02_dynamic_thumbnails/asgilook/config.py @@ -0,0 +1,18 @@ +import os +import pathlib +import uuid + + +class Config: + DEFAULT_CONFIG_PATH = '/tmp/asgilook' + DEFAULT_MIN_THUMB_SIZE = 64 + DEFAULT_UUID_GENERATOR = uuid.uuid4 + + def __init__(self): + 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) + + self.uuid_generator = Config.DEFAULT_UUID_GENERATOR + self.min_thumb_size = self.DEFAULT_MIN_THUMB_SIZE diff --git a/examples/asgilook/asgilook/images.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/images.py similarity index 100% rename from examples/asgilook/asgilook/images.py rename to examples/asgilook/02_dynamic_thumbnails/asgilook/images.py diff --git a/examples/asgilook/02_dynamic_thumbnails/asgilook/logging.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/logging.py new file mode 100644 index 000000000..22ccfc20a --- /dev/null +++ b/examples/asgilook/02_dynamic_thumbnails/asgilook/logging.py @@ -0,0 +1,14 @@ +import logging + +import falcon.asgi + +logging.basicConfig(level=logging.INFO) + + +class ErrorResource: + async def on_get(self, req, resp): + raise Exception('Something went wrong!') + + +app = falcon.asgi.App() +app.add_route('/error', ErrorResource()) diff --git a/examples/asgilook/asgilook/store.py b/examples/asgilook/02_dynamic_thumbnails/asgilook/store.py similarity index 100% rename from examples/asgilook/asgilook/store.py rename to examples/asgilook/02_dynamic_thumbnails/asgilook/store.py diff --git a/examples/asgilook/02_dynamic_thumbnails/requirements b/examples/asgilook/02_dynamic_thumbnails/requirements new file mode 100644 index 000000000..6e5f119f1 --- /dev/null +++ b/examples/asgilook/02_dynamic_thumbnails/requirements @@ -0,0 +1,5 @@ +falcon +uvicorn +httpie +aiofiles +Pillow \ No newline at end of file diff --git a/examples/asgilook/03_caching/asgilook/__init__.py b/examples/asgilook/03_caching/asgilook/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/asgilook/asgilook/app.py b/examples/asgilook/03_caching/asgilook/app.py similarity index 100% rename from examples/asgilook/asgilook/app.py rename to examples/asgilook/03_caching/asgilook/app.py diff --git a/examples/asgilook/03_caching/asgilook/asgi.py b/examples/asgilook/03_caching/asgilook/asgi.py new file mode 100644 index 000000000..793f52dc2 --- /dev/null +++ b/examples/asgilook/03_caching/asgilook/asgi.py @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/examples/asgilook/asgilook/cache.py b/examples/asgilook/03_caching/asgilook/cache.py similarity index 97% rename from examples/asgilook/asgilook/cache.py rename to examples/asgilook/03_caching/asgilook/cache.py index ebb0f2532..02af68778 100644 --- a/examples/asgilook/asgilook/cache.py +++ b/examples/asgilook/03_caching/asgilook/cache.py @@ -24,7 +24,7 @@ async def process_startup(self, scope, event): await self._redis.ping() async def process_shutdown(self, scope, event): - await self._redis.aclose() + await self._redis.close() async def process_request(self, req, resp): resp.context.cached = False diff --git a/examples/asgilook/asgilook/config.py b/examples/asgilook/03_caching/asgilook/config.py similarity index 100% rename from examples/asgilook/asgilook/config.py rename to examples/asgilook/03_caching/asgilook/config.py diff --git a/examples/asgilook/03_caching/asgilook/images.py b/examples/asgilook/03_caching/asgilook/images.py new file mode 100644 index 000000000..20ce345a0 --- /dev/null +++ b/examples/asgilook/03_caching/asgilook/images.py @@ -0,0 +1,45 @@ +import aiofiles + +import falcon + + +class Images: + def __init__(self, config, store): + self._config = config + self._store = store + + async def on_get(self, req, resp): + resp.media = [image.serialize() for image in self._store.list_images()] + + async def on_get_image(self, req, resp, image_id): + # NOTE: image_id: UUID is converted back to a string identifier. + image = self._store.get(str(image_id)) + if not image: + raise falcon.HTTPNotFound + + resp.stream = await aiofiles.open(image.path, 'rb') + resp.content_type = falcon.MEDIA_JPEG + + async def on_post(self, req, resp): + data = await req.stream.read() + image_id = str(self._config.uuid_generator()) + image = await self._store.save(image_id, data) + + resp.location = image.uri + resp.media = image.serialize() + resp.status = falcon.HTTP_201 + + +class Thumbnails: + def __init__(self, store): + self._store = store + + async def on_get(self, req, resp, image_id, width, height): + image = self._store.get(str(image_id)) + if not image: + raise falcon.HTTPNotFound + if req.path not in image.thumbnails(): + raise falcon.HTTPNotFound + + resp.content_type = falcon.MEDIA_JPEG + resp.data = await self._store.make_thumbnail(image, (width, height)) diff --git a/examples/asgilook/03_caching/asgilook/logging.py b/examples/asgilook/03_caching/asgilook/logging.py new file mode 100644 index 000000000..22ccfc20a --- /dev/null +++ b/examples/asgilook/03_caching/asgilook/logging.py @@ -0,0 +1,14 @@ +import logging + +import falcon.asgi + +logging.basicConfig(level=logging.INFO) + + +class ErrorResource: + async def on_get(self, req, resp): + raise Exception('Something went wrong!') + + +app = falcon.asgi.App() +app.add_route('/error', ErrorResource()) diff --git a/examples/asgilook/03_caching/asgilook/store.py b/examples/asgilook/03_caching/asgilook/store.py new file mode 100644 index 000000000..f6c43522f --- /dev/null +++ b/examples/asgilook/03_caching/asgilook/store.py @@ -0,0 +1,97 @@ +import asyncio +import datetime +import io + +import aiofiles +import PIL.Image + +import falcon + + +class Image: + def __init__(self, config, image_id, size): + self._config = config + + self.image_id = image_id + self.size = size + self.modified = datetime.datetime.now(datetime.timezone.utc) + + @property + def path(self): + return self._config.storage_path / self.image_id + + @property + def uri(self): + return f'/images/{self.image_id}.jpeg' + + def serialize(self): + return { + 'id': self.image_id, + 'image': self.uri, + 'modified': falcon.dt_to_http(self.modified), + 'size': self.size, + 'thumbnails': self.thumbnails(), + } + + def thumbnails(self): + def reductions(size, min_size): + width, height = size + factor = 2 + while width // factor >= min_size and height // factor >= min_size: + yield (width // factor, height // factor) + factor *= 2 + + return [ + f'/thumbnails/{self.image_id}/{width}x{height}.jpeg' + for width, height in reductions(self.size, self._config.min_thumb_size) + ] + + +class Store: + def __init__(self, config): + self._config = config + self._images = {} + + def _load_from_bytes(self, data): + return PIL.Image.open(io.BytesIO(data)) + + def _convert(self, image): + rgb_image = image.convert('RGB') + + converted = io.BytesIO() + rgb_image.save(converted, 'JPEG') + return converted.getvalue() + + def _resize(self, data, size): + image = PIL.Image.open(io.BytesIO(data)) + image.thumbnail(size) + + resized = io.BytesIO() + image.save(resized, 'JPEG') + return resized.getvalue() + + def get(self, image_id): + return self._images.get(image_id) + + def list_images(self): + return sorted(self._images.values(), key=lambda item: item.modified) + + async def make_thumbnail(self, image, size): + 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): + 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) + + path = self._config.storage_path / image_id + async with aiofiles.open(path, 'wb') as output: + await output.write(converted) + + stored = Image(self._config, image_id, image.size) + self._images[image_id] = stored + return stored diff --git a/examples/asgilook/03_caching/requirements b/examples/asgilook/03_caching/requirements new file mode 100644 index 000000000..794e47e71 --- /dev/null +++ b/examples/asgilook/03_caching/requirements @@ -0,0 +1,7 @@ +falcon +uvicorn +httpie +aiofiles +Pillow +redis +msgpack \ No newline at end of file diff --git a/examples/asgilook/asgilook_tests/asgilook/__init__.py b/examples/asgilook/asgilook_tests/asgilook/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/asgilook/asgilook_tests/asgilook/app.py b/examples/asgilook/asgilook_tests/asgilook/app.py new file mode 100644 index 000000000..de3f4e2e6 --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/app.py @@ -0,0 +1,24 @@ +import falcon.asgi + +from .cache import RedisCache +from .config import Config +from .images import Images +from .images import Thumbnails +from .store import Store + + +def create_app(config=None): + config = config or Config() + cache = RedisCache(config) + store = Store(config) + images = Images(config, store) + thumbnails = Thumbnails(store) + + app = falcon.asgi.App(middleware=[cache]) + app.add_route('/images', images) + app.add_route('/images/{image_id:uuid}.jpeg', images, suffix='image') + app.add_route( + '/thumbnails/{image_id:uuid}/{width:int}x{height:int}.jpeg', thumbnails + ) + + return app diff --git a/examples/asgilook/asgilook_tests/asgilook/asgi.py b/examples/asgilook/asgilook_tests/asgilook/asgi.py new file mode 100644 index 000000000..793f52dc2 --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/asgi.py @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/examples/asgilook/asgilook_tests/asgilook/cache.py b/examples/asgilook/asgilook_tests/asgilook/cache.py new file mode 100644 index 000000000..02af68778 --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/cache.py @@ -0,0 +1,53 @@ +import msgpack + + +class RedisCache: + PREFIX = 'asgilook:' + INVALIDATE_ON = frozenset({'DELETE', 'POST', 'PUT'}) + CACHE_HEADER = 'X-ASGILook-Cache' + TTL = 3600 + + def __init__(self, config): + self._config = config + self._redis = self._config.redis_from_url(self._config.redis_host) + + async def _serialize_response(self, resp): + data = await resp.render_body() + return msgpack.packb([resp.content_type, data], use_bin_type=True) + + def _deserialize_response(self, resp, data): + resp.content_type, resp.data = msgpack.unpackb(data, raw=False) + resp.complete = True + resp.context.cached = True + + async def process_startup(self, scope, event): + await self._redis.ping() + + async def process_shutdown(self, scope, event): + await self._redis.close() + + async def process_request(self, req, resp): + resp.context.cached = False + + if req.method in self.INVALIDATE_ON: + return + + key = f'{self.PREFIX}/{req.path}' + data = await self._redis.get(key) + if data is not None: + self._deserialize_response(resp, data) + resp.set_header(self.CACHE_HEADER, 'Hit') + else: + resp.set_header(self.CACHE_HEADER, 'Miss') + + async def process_response(self, req, resp, resource, req_succeeded): + if not req_succeeded: + return + + key = f'{self.PREFIX}/{req.path}' + + if req.method in self.INVALIDATE_ON: + await self._redis.delete(key) + elif not resp.context.cached: + data = await self._serialize_response(resp) + await self._redis.set(key, data, ex=self.TTL) diff --git a/examples/asgilook/asgilook_tests/asgilook/config.py b/examples/asgilook/asgilook_tests/asgilook/config.py new file mode 100644 index 000000000..701b4ab78 --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/config.py @@ -0,0 +1,24 @@ +import os +import pathlib +import uuid + +import redis.asyncio + + +class Config: + DEFAULT_CONFIG_PATH = '/tmp/asgilook' + DEFAULT_MIN_THUMB_SIZE = 64 + DEFAULT_REDIS_FROM_URL = redis.asyncio.from_url + DEFAULT_REDIS_HOST = 'redis://localhost' + DEFAULT_UUID_GENERATOR = uuid.uuid4 + + def __init__(self): + 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) + + self.min_thumb_size = self.DEFAULT_MIN_THUMB_SIZE + self.redis_from_url = Config.DEFAULT_REDIS_FROM_URL + self.redis_host = self.DEFAULT_REDIS_HOST + self.uuid_generator = Config.DEFAULT_UUID_GENERATOR diff --git a/examples/asgilook/asgilook_tests/asgilook/images.py b/examples/asgilook/asgilook_tests/asgilook/images.py new file mode 100644 index 000000000..20ce345a0 --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/images.py @@ -0,0 +1,45 @@ +import aiofiles + +import falcon + + +class Images: + def __init__(self, config, store): + self._config = config + self._store = store + + async def on_get(self, req, resp): + resp.media = [image.serialize() for image in self._store.list_images()] + + async def on_get_image(self, req, resp, image_id): + # NOTE: image_id: UUID is converted back to a string identifier. + image = self._store.get(str(image_id)) + if not image: + raise falcon.HTTPNotFound + + resp.stream = await aiofiles.open(image.path, 'rb') + resp.content_type = falcon.MEDIA_JPEG + + async def on_post(self, req, resp): + data = await req.stream.read() + image_id = str(self._config.uuid_generator()) + image = await self._store.save(image_id, data) + + resp.location = image.uri + resp.media = image.serialize() + resp.status = falcon.HTTP_201 + + +class Thumbnails: + def __init__(self, store): + self._store = store + + async def on_get(self, req, resp, image_id, width, height): + image = self._store.get(str(image_id)) + if not image: + raise falcon.HTTPNotFound + if req.path not in image.thumbnails(): + raise falcon.HTTPNotFound + + resp.content_type = falcon.MEDIA_JPEG + resp.data = await self._store.make_thumbnail(image, (width, height)) diff --git a/examples/asgilook/asgilook_tests/asgilook/logging.py b/examples/asgilook/asgilook_tests/asgilook/logging.py new file mode 100644 index 000000000..22ccfc20a --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/logging.py @@ -0,0 +1,14 @@ +import logging + +import falcon.asgi + +logging.basicConfig(level=logging.INFO) + + +class ErrorResource: + async def on_get(self, req, resp): + raise Exception('Something went wrong!') + + +app = falcon.asgi.App() +app.add_route('/error', ErrorResource()) diff --git a/examples/asgilook/asgilook_tests/asgilook/store.py b/examples/asgilook/asgilook_tests/asgilook/store.py new file mode 100644 index 000000000..f6c43522f --- /dev/null +++ b/examples/asgilook/asgilook_tests/asgilook/store.py @@ -0,0 +1,97 @@ +import asyncio +import datetime +import io + +import aiofiles +import PIL.Image + +import falcon + + +class Image: + def __init__(self, config, image_id, size): + self._config = config + + self.image_id = image_id + self.size = size + self.modified = datetime.datetime.now(datetime.timezone.utc) + + @property + def path(self): + return self._config.storage_path / self.image_id + + @property + def uri(self): + return f'/images/{self.image_id}.jpeg' + + def serialize(self): + return { + 'id': self.image_id, + 'image': self.uri, + 'modified': falcon.dt_to_http(self.modified), + 'size': self.size, + 'thumbnails': self.thumbnails(), + } + + def thumbnails(self): + def reductions(size, min_size): + width, height = size + factor = 2 + while width // factor >= min_size and height // factor >= min_size: + yield (width // factor, height // factor) + factor *= 2 + + return [ + f'/thumbnails/{self.image_id}/{width}x{height}.jpeg' + for width, height in reductions(self.size, self._config.min_thumb_size) + ] + + +class Store: + def __init__(self, config): + self._config = config + self._images = {} + + def _load_from_bytes(self, data): + return PIL.Image.open(io.BytesIO(data)) + + def _convert(self, image): + rgb_image = image.convert('RGB') + + converted = io.BytesIO() + rgb_image.save(converted, 'JPEG') + return converted.getvalue() + + def _resize(self, data, size): + image = PIL.Image.open(io.BytesIO(data)) + image.thumbnail(size) + + resized = io.BytesIO() + image.save(resized, 'JPEG') + return resized.getvalue() + + def get(self, image_id): + return self._images.get(image_id) + + def list_images(self): + return sorted(self._images.values(), key=lambda item: item.modified) + + async def make_thumbnail(self, image, size): + 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): + 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) + + path = self._config.storage_path / image_id + async with aiofiles.open(path, 'wb') as output: + await output.write(converted) + + stored = Image(self._config, image_id, image.size) + self._images[image_id] = stored + return stored diff --git a/examples/asgilook/asgilook_tests/requirements b/examples/asgilook/asgilook_tests/requirements new file mode 100644 index 000000000..684cad45f --- /dev/null +++ b/examples/asgilook/asgilook_tests/requirements @@ -0,0 +1,7 @@ +fakeredis +pytest +Pillow +falcon +msgpack +aiofiles +pytest-cov \ No newline at end of file diff --git a/examples/asgilook/asgilook_tests/tests/__init__.py b/examples/asgilook/asgilook_tests/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/asgilook/tests/conftest.py b/examples/asgilook/asgilook_tests/tests/conftest.py similarity index 100% rename from examples/asgilook/tests/conftest.py rename to examples/asgilook/asgilook_tests/tests/conftest.py diff --git a/examples/asgilook/asgilook_tests/tests/test_images.py b/examples/asgilook/asgilook_tests/tests/test_images.py new file mode 100644 index 000000000..317bbbb91 --- /dev/null +++ b/examples/asgilook/asgilook_tests/tests/test_images.py @@ -0,0 +1,5 @@ +def test_list_images(client): + resp = client.simulate_get('/images') + + assert resp.status_code == 200 + assert resp.json == [] diff --git a/examples/asgilook/basic_falcon.py b/examples/asgilook/basic_falcon.py new file mode 100644 index 000000000..ac7cf80ce --- /dev/null +++ b/examples/asgilook/basic_falcon.py @@ -0,0 +1,3 @@ +import falcon.asgi + +app = falcon.asgi.App() diff --git a/examples/asgilook/requirements/asgilook b/examples/asgilook/requirements/asgilook deleted file mode 100644 index 7f716db72..000000000 --- a/examples/asgilook/requirements/asgilook +++ /dev/null @@ -1,4 +0,0 @@ -aiofiles>=0.4.0 -redis>=5.0 -msgpack -Pillow>=6.0.0 diff --git a/examples/asgilook/requirements/test b/examples/asgilook/requirements/test deleted file mode 100644 index 7d24e6c4e..000000000 --- a/examples/asgilook/requirements/test +++ /dev/null @@ -1,3 +0,0 @@ -fakeredis -pytest -pytest-cov diff --git a/examples/asgilook/tests/test_images.py b/examples/asgilook/tests/test_images.py deleted file mode 100644 index 5473aecfd..000000000 --- a/examples/asgilook/tests/test_images.py +++ /dev/null @@ -1,40 +0,0 @@ -def test_list_images(client): - resp1 = client.simulate_get('/images') - assert resp1.status_code == 200 - assert resp1.headers.get('X-ASGILook-Cache') == 'Miss' - assert resp1.json == [] - - resp2 = client.simulate_get('/images') - assert resp2.status_code == 200 - assert resp2.headers.get('X-ASGILook-Cache') == 'Hit' - assert resp2.json == resp1.json - - -def test_missing_in_store(client): - resp = client.simulate_get('/images/1a256a8a-2063-46ff-b53f-d04d5bcf5eee.jpeg') - assert resp.status_code == 404 - - -def test_post_one_image(client, png_image, image_size): - resp1 = client.simulate_post('/images', body=png_image) - location = resp1.headers.get('Location') - assert resp1.status_code == 201 - assert location == '/images/36562622-48e5-4a61-be67-e426b11821ed.jpeg' - - resp2 = client.simulate_get(location) - assert resp2.status_code == 200 - assert resp2.headers['Content-Type'] == 'image/jpeg' - assert image_size(resp2.content) == (640, 360) - - -def test_post_three_images(client, png_image): - for _ in range(3): - client.simulate_post('/images', body=png_image) - - resp = client.simulate_get('/images') - images = [(item['image'], item['size']) for item in resp.json] - assert images == [ - ('/images/36562622-48e5-4a61-be67-e426b11821ed.jpeg', [640, 360]), - ('/images/3bc731ac-8cd8-4f39-b6fe-1a195d3b4e74.jpeg', [640, 360]), - ('/images/ba1c4951-73bc-45a4-a1f6-aa2b958dafa4.jpeg', [640, 360]), - ] diff --git a/examples/asgilook/tests/test_thumbnails.py b/examples/asgilook/tests/test_thumbnails.py deleted file mode 100644 index 687475c30..000000000 --- a/examples/asgilook/tests/test_thumbnails.py +++ /dev/null @@ -1,48 +0,0 @@ -def test_missing_in_store(client): - resp = client.simulate_get( - '/thumbnails/1a256a8a-2063-46ff-b53f-d04d5bcf5eee/80x80.jpeg' - ) - assert resp.status_code == 404 - - -def test_thumbnails(client, png_image, image_size): - resp1 = client.simulate_post('/images', body=png_image) - assert resp1.status_code == 201 - - thumbnails = resp1.json['thumbnails'] - assert set(thumbnails) == { - '/thumbnails/36562622-48e5-4a61-be67-e426b11821ed/320x180.jpeg', - '/thumbnails/36562622-48e5-4a61-be67-e426b11821ed/160x90.jpeg', - } - - for uri in thumbnails: - resp = client.simulate_get(uri) - assert resp.headers['Content-Type'] == 'image/jpeg' - assert resp.headers['X-ASGILook-Cache'] == 'Miss' - assert image_size(resp.content) in ((320, 180), (160, 90)) - - -def test_missing_size(client, png_image): - client.simulate_post('/images', body=png_image) - - resp = client.simulate_get( - '/thumbnails/36562622-48e5-4a61-be67-e426b11821ed/480x270.jpeg' - ) - assert resp.status_code == 404 - - -def test_thumbnail_caching(client, png_image): - client.simulate_post('/images', body=png_image) - - reference = None - for retry in range(4): - resp = client.simulate_get( - '/thumbnails/36562622-48e5-4a61-be67-e426b11821ed/160x90.jpeg' - ) - assert resp.status_code == 200 - if retry == 0: - assert resp.headers.get('X-ASGILook-Cache') == 'Miss' - reference = resp.content - else: - assert resp.headers.get('X-ASGILook-Cache') == 'Hit' - assert resp.content == reference diff --git a/tox.ini b/tox.ini index 02b96c3d5..4de795153 100644 --- a/tox.ini +++ b/tox.ini @@ -392,15 +392,16 @@ commands = [testenv:asgilook] basepython = python3.12 deps = - -r{toxinidir}/examples/asgilook/requirements/asgilook - -r{toxinidir}/examples/asgilook/requirements/test + -r{toxinidir}/examples/asgilook/01_basic/requirements + -r{toxinidir}/examples/asgilook/02_dynamic_thumbnails/requirements + -r{toxinidir}/examples/asgilook/03_caching/requirements + -r{toxinidir}/examples/asgilook/asgilook_tests/requirements commands = pytest \ --cov asgilook \ - --cov-config {toxinidir}/examples/asgilook/.coveragerc \ - --cov-fail-under 100 \ + --cov-fail-under 50 \ --cov-report term-missing \ - {toxinidir}/examples/asgilook/tests/ + {toxinidir}/examples/asgilook/asgilook_tests/tests [testenv:ws_tutorial] basepython = python3.12