{"id":18269601,"url":"https://github.com/rockyburt/ketchup","last_synced_at":"2025-06-11T02:33:19.980Z","repository":{"id":140293109,"uuid":"407967820","full_name":"rockyburt/Ketchup","owner":"rockyburt","description":"A very basic web application and tutorial for demonstrating usage of the Quart and Strawberry GraphQL frameworks/libraries.","archived":false,"fork":false,"pushed_at":"2021-09-26T16:34:58.000Z","size":158,"stargazers_count":8,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-04-04T23:35:03.122Z","etag":null,"topics":["asgi","asyncio","asyncpg","poetry","postgresql","pytest","python","python3","quart","sqlalchemy","strawberry-graphql"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rockyburt.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-09-18T21:06:52.000Z","updated_at":"2024-10-20T04:36:33.000Z","dependencies_parsed_at":null,"dependency_job_id":"1f56a5c5-a33a-4fc5-b151-60cdec00eab6","html_url":"https://github.com/rockyburt/Ketchup","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rockyburt%2FKetchup","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rockyburt%2FKetchup/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rockyburt%2FKetchup/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rockyburt%2FKetchup/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rockyburt","download_url":"https://codeload.github.com/rockyburt/Ketchup/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rockyburt%2FKetchup/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":259186391,"owners_count":22818550,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["asgi","asyncio","asyncpg","poetry","postgresql","pytest","python","python3","quart","sqlalchemy","strawberry-graphql"],"created_at":"2024-11-05T11:36:34.566Z","updated_at":"2025-06-11T02:33:19.936Z","avatar_url":"https://github.com/rockyburt.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Ketchup\n\n*Ketchup* is a simple web app for learning how to build web apps using recent Python technologies\nwith an emphasis on asynchronous I/O (asyncio) programming.\n\nProject Source\n: \u003chttps://github.com/rockyburt/Ketchup\u003e\n\nPart 1 and 2 Source:\n: \u003chttps://github.com/rockyburt/Ketchup/tree/part/1-2\u003e\n\nPart 3 Source:\n: \u003chttps://github.com/rockyburt/Ketchup/tree/part/3\u003e\n\n## Prerequisites\n\n- A Linux or Linux-like work environment (all steps were tested and run on *Ubuntu 20.04* inside the *WSL* environment of *Windows 10*)\n- A working Python 3.9 installation\n\n**NOTE**: Theoretically these steps will run (using platform/OS-equivalent steps) on most modern OS's but the developer's mileage may vary.\n\n## `Quart` + `Strawberry-GraphQL` Tutorial\n\nThe following tutorial explains how to use *Quart* and *Strawberry-GraphQL* to build a simple web application with\na GraphQL-based API.  All example code is written to heavily lean on `asyncio` based programming with a focus\non including *type hint* support in a Python setting.\n\n**Requirements:** *Python 3.9 or higher*\n\n### Part 1: Getting Started with Quart\n\n*Quart* is a web framework heavily inspired by the [Flask](https://flask.palletsprojects.com/en/2.0.x/) framework.  It\nstrives to be API-compatible with Flask with method and class signatures only differing to allow for the Python `async/await`\napproach to asynchronous I/O programming.\n\n1. Install Poetry via \u003chttps://python-poetry.org/docs/master/#installation\u003e\n\n2. Create new *Ketchup* project\n\n    ```sh\n    poetry new Ketchup\n    ```\n\n3. Go into the newly created `Ketchup` directory and add *Quart* to Poetry's requirements files. Also include Hypercorn for it's ASGI running expertise.\n\n    ```sh\n    cd Ketchup\n    poetry add Quart hypercorn\n    ```\n\n    At the time of writing this doc, the versions installed were:\n    - `Quart 0.15.1`\n\n4. Setup skeleton web app:\n\n    Create new file `Ketchup/ketchup/webapp.py` with the following content:\n\n    ```python\n    from quart import Quart\n\n    app = Quart(__name__)\n\n    @app.route('/')\n    async def index():\n        return 'Hello World'\n\n\n    if __name__ == \"__main__\":\n        app.run()\n\n    ```\n\n5. Test new application by running the following from inside the `Ketchup` project directory.\n\n    ```sh\n    poetry run python ketchup/webapp.py\n    ```\n\n    And open up the following in your browser: \u003chttp://localhost:5000\u003e\n\n### Part 2: Adding Strawberry\n\n*Strawberry* is a library for building GraphQL web applications on top of Python and (preferably but not limited to)\n`asyncio` web frameworks.  The goal is to define GraphQL schema's using the `dataclasses` package which is part\nof the Python *stdlib*.\n\n1. Add *Strawberry-GraphQL* to Poetry's requirements files.\n\n    ```sh\n    poetry add Strawberry-graphql\n    ```\n\n    At the time of writing this doc, the versions installed were:\n\n    - `Strawberry-graphql 0.77.10`\n\n2. Create a new module at `Ketchup/ketchup/gqlschema.py`\n\n    ```python\n    import strawberry\n\n\n    @strawberry.type\n    class Query:\n        @strawberry.field\n        def upper(self, val: str) -\u003e str:\n            return val.upper()\n    ```\n\n3. Create new module for embedding the strawberry graphql endpoint as a standard *Quart* view as `Ketchup/ketchup/strawview.py`\n\n    ```python\n    import json\n    import logging\n    import pathlib\n    import traceback\n    from typing import Any, Union\n\n    import strawberry\n    from quart import Response, abort, render_template_string, request\n    from quart.typing import ResponseReturnValue\n    from quart.views import View\n    from strawberry.exceptions import MissingQueryError\n    from strawberry.file_uploads.utils import replace_placeholders_with_files\n    from strawberry.http import (GraphQLHTTPResponse, parse_request_data,\n                                process_result)\n    from strawberry.schema import BaseSchema\n    from strawberry.types import ExecutionResult\n\n    logger = logging.getLogger(\"ketchup\")\n\n\n    def render_graphiql_page() -\u003e str:\n        dir_path = pathlib.Path(strawberry.__file__).absolute().parent\n        graphiql_html_file = f\"{dir_path}/static/graphiql.html\"\n\n        html_string = None\n\n        with open(graphiql_html_file, \"r\") as f:\n            html_string = f.read()\n\n        return html_string.replace(\"{{ SUBSCRIPTION_ENABLED }}\", \"false\")\n\n\n    class GraphQLView(View):\n        methods = [\"GET\", \"POST\"]\n\n        def __init__(self, schema: BaseSchema, graphiql: bool = True):\n            self.schema = schema\n            self.graphiql = graphiql\n\n        async def process_result(self, result: ExecutionResult) -\u003e GraphQLHTTPResponse:\n            if result.errors:\n                for error in result.errors:\n                    err = getattr(error, \"original_error\", None) or error\n                    formatted = \"\".join(traceback.format_exception(err.__class__, err, err.__traceback__))\n                    logger.error(formatted)\n\n            return process_result(result)\n\n        async def dispatch_request(self, *args: Any, **kwargs: Any) -\u003e Union[ResponseReturnValue, str]:\n            if \"text/html\" in request.headers.get(\"Accept\", \"\"):\n                if not self.graphiql:\n                    abort(404)\n\n                template = render_graphiql_page()\n                return await render_template_string(template)\n\n            content_type = str(request.headers.get(\"content-type\", \"\"))\n            if content_type.startswith(\"multipart/form-data\"):\n                form = await request.form\n                operations = json.loads(form.get(\"operations\", \"{}\"))\n                files_map = json.loads(form.get(\"map\", \"{}\"))\n\n                data = replace_placeholders_with_files(operations, files_map, await request.files)\n            else:\n                data = await request.get_json()\n\n            try:\n                request_data = parse_request_data(data)\n            except MissingQueryError:\n                return Response(\"No valid query was provided for the request\", 400)\n\n            context = {\"request\": request}\n\n            result = await self.schema.execute(\n                request_data.query,\n                variable_values=request_data.variables,\n                context_value=context,\n                operation_name=request_data.operation_name,\n                root_value=None,\n            )\n\n            response_data = await self.process_result(result)\n\n            return Response(\n                json.dumps(response_data),\n                status=200,\n                content_type=\"application/json\",\n            )\n    ```\n\n4. Modify file `Ketchup/ketchup/webapp.py` to have the following content:\n\n    ```python\n    import asyncio\n\n    from hypercorn.asyncio import serve\n    from hypercorn.config import Config\n    from quart import Quart\n    from strawberry import Schema\n\n    from ketchup.gqlschema import Query\n    from ketchup.strawview import GraphQLView\n\n    app = Quart(\"ketchup\")\n    schema = Schema(Query)\n\n\n    app.add_url_rule(\"/graphql\", view_func=GraphQLView.as_view(\"graphql_view\", schema=schema))\n\n\n    @app.route(\"/\")\n    async def index():\n        return 'Welcome to Ketchup!  Please see \u003ca href=\"/graphql\"\u003eGraph\u003cem\u003ei\u003c/em\u003eQL\u003c/a\u003e to interact with the GraphQL endpoint.'\n\n\n    def hypercorn_serve():\n        config = Config()\n        config.bind = [\"0.0.0.0:5000\"]\n        config.use_reloader = True\n        asyncio.run(serve(app, config, shutdown_trigger=lambda: asyncio.Future()))\n\n\n    if __name__ == \"__main__\":\n        hypercorn_serve()\n    ```\n\n5. Test new application by running the following from inside the `Ketchup` project directory.\n\n    a. Run the updated web app\n\n    ```sh\n    poetry run python -m ketchup.webapp\n    ```\n\n    b. Open up the following in your browser: \u003chttp://localhost:5000/graphql\u003e\n\n    c. Input the following graph query into the left side text area and hit the *play* button.\n\n    ```graphql\n    query {\n      upper(val: \"dude, where's my car?\")\n    }\n    ```\n\n    The result should be (on the right side):\n\n    ```json\n    {\n    \"data\": {\n        \"upper\": \"DUDE, WHERE'S MY CAR?\"\n    }    \n    ```\n\n### Bonus Points - Running Tests\n\n1. Add *pytest* as a dependency.\n\n    ```sh\n    # The ^6.2 version identifier for pytest is required due to other dependencies pulling down older versions of pytest\n    poetry add -D \"pytest^6.2\" pytest-asyncio\n    ```\n\n2. Ensure the `Ketchup/tests/test_ketchup.py` file exists with the following content:\n\n    ```python\n    import pytest\n\n    from ketchup import __version__, webapp\n\n\n    def test_version():\n        assert __version__ == \"0.1.0\"\n\n\n    @pytest.mark.asyncio\n    class TestViews:\n        async def test_index(self):\n            assert \"Welcome\" in (await webapp.index())\n    ```\n\n3. Run the tests by issuing the following from *inside* the `Ketchup` directory.\n\n    ```sh\n    poetry run pytest\n    ```\n\n    The result should be something like:\n\n    ```text\n    =============================== test session starts ===============================\n    platform linux -- Python 3.9.5, pytest-5.4.3, py-1.10.0, pluggy-0.13.1\n    rootdir: /home/ubuntu/dev/Ketchup\n    plugins: anyio-3.3.1, asyncio-0.15.1\n    collected 2 items\n\n    tests/test_ketchup.py ..                                                    [100%]\n\n    ================================ 2 passed in 0.16s ================================\n    ```\n\n### Coding Conventions\n\nIt is the author's advice to add the following to help with formatting all code in a standard way.\n\n- Add some developer dependencies:\n\n    ```sh\n    poetry add -D black isort\n    ```\n\n    The standard *Python* method for activating these formatters would be to append something like the following to `Ketchup/pyproject.toml`.\n\n    ```toml\n    exclude = '''\n    /(\n        \\.git\n      | \\.tox\n      | \\.venv\n      | build\n      | dist\n    )/   \n    '''\n    line-length = 119  # standard editor width used by github\n\n    [tool.isort]\n    profile = \"black\"\n    ```\n\n### Part 3a: Persistence with SQLAlchemy and PostgreSQL\n\nThis is where the project actually starts getting useful.  We are building a **ToDo** application that can persist\ntodo records to a PostgreSQL database.  The following steps assume you are within the `Ketchup` directory.\n\n1. Add *SQLAlchemy*, *alembic*, and *asyncpg* as a dependencies.\n\n    *As of the writing of this tutorial, SQLAlchemy 1.4.23 is the most recent ... with SQLAlchemy 1.4.x the first version to\n    include asyncio support.  In addition, `asyncio` support comes only from PostgreSQL and the `asyncpg` db adapter.*\n\n    ```sh\n    poetry add sqlalchemy alembic asyncpg\n\n    # for anyone using mypy/pylance/pyright/vscode ... adding the following should provide better type hint support\n    # but is optional and won't affect the application\n    poetry add -D sqlalchemy2.stubs\n    ```\n\n2. Initialize the *Alembic* database migration framework.\n\n    ```sh\n    poetry run alembic init --template async alembic\n    ```\n\n3. Modify `Ketchup/alembic/env.py` so that it uses the same postgres db configuration as the rest of the web app.\nThe file should look like this:\n\n    ```python\n    import asyncio\n    from logging.config import fileConfig\n\n    from sqlalchemy import pool\n    from sqlalchemy.ext.asyncio import create_async_engine\n\n    from alembic import context\n    from ketchup import sqlamodels, base\n\n    # this is the Alembic Config object, which provides\n    # access to the values within the .ini file in use.\n    config = context.config\n\n    # Interpret the config file for Python logging.\n    # This line sets up loggers basically.\n    assert config.config_file_name is not None\n    fileConfig(config.config_file_name)\n\n    # add your model's MetaData object here\n    # for 'autogenerate' support\n    # from myapp import mymodel\n    # target_metadata = mymodel.Base.metadata\n    target_metadata = sqlamodels.mapper_registry.metadata\n\n    # other values from the config, defined by the needs of env.py,\n    # can be acquired:\n    # my_important_option = config.get_main_option(\"my_important_option\")\n    # ... etc.\n\n\n    def run_migrations_offline():\n        \"\"\"Run migrations in 'offline' mode.\n\n        This configures the context with just a URL\n        and not an Engine, though an Engine is acceptable\n        here as well.  By skipping the Engine creation\n        we don't even need a DBAPI to be available.\n\n        Calls to context.execute() here emit the given string to the\n        script output.\n\n        \"\"\"\n        url = config.get_main_option(\"sqlalchemy.url\")\n        context.configure(\n            url=url,\n            target_metadata=target_metadata,\n            literal_binds=True,\n            dialect_opts={\"paramstyle\": \"named\"},\n        )\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\n    def do_run_migrations(connection):\n        context.configure(connection=connection, target_metadata=target_metadata)\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\n    async def run_migrations_online():\n        \"\"\"Run migrations in 'online' mode.\n\n        In this scenario we need to create an Engine\n        and associate a connection with the context.\n\n        \"\"\"\n\n        connectable = create_async_engine(\n            base.config.DB_URI,\n            future=True,\n            poolclass=pool.NullPool,\n        )\n\n        async with connectable.connect() as connection:\n            await connection.run_sync(do_run_migrations)\n\n\n    if context.is_offline_mode():\n        run_migrations_offline()\n    else:\n        asyncio.run(run_migrations_online())\n    ```\n\n4. Setup the initial *SQLAlchemy* models by creating ``Ketchup/ketchup/sqlamodels.py``.\n\n    ```python\n    import asyncio\n    import dataclasses\n    import datetime\n    import typing\n    from contextlib import asynccontextmanager\n\n    from sqlalchemy import Column, DateTime, Integer, Table, Text\n    from sqlalchemy.ext.asyncio import (\n        AsyncSession,\n        async_scoped_session,\n        create_async_engine,\n    )\n    from sqlalchemy.orm import registry, sessionmaker\n\n    from ketchup import base\n\n    mapper_registry = registry()\n\n    _async_engine = create_async_engine(base.config.DB_URI, future=True)\n    _async_session_factory = sessionmaker(_async_engine, class_=AsyncSession, expire_on_commit=False)  # type: ignore - having some pyright issues\n\n    _get_session = async_scoped_session(_async_session_factory, scopefunc=asyncio.current_task)\n\n\n    @asynccontextmanager\n    async def atomic_session() -\u003e typing.AsyncIterator[AsyncSession]:\n        async with _get_session() as session:\n            try:\n                yield session\n                await session.commit()\n            except Exception:\n                await session.rollback()\n                raise\n\n\n    @mapper_registry.mapped\n    @dataclasses.dataclass\n    class Todo:\n        __table__ = Table(\n            \"ketchup_todo\",\n            mapper_registry.metadata,\n            Column(\n                \"id\",\n                Integer,\n                primary_key=True,\n                autoincrement=True,\n                nullable=False,\n            ),\n            Column(\"text\", Text(), nullable=False),\n            Column(\"created\", DateTime(True), nullable=False),\n            Column(\"completed\", DateTime(True), nullable=True),\n        )\n\n        id: int = dataclasses.field(init=False)\n        text: str\n        created: datetime.datetime = dataclasses.field(\n            default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)\n        )\n        completed: typing.Optional[datetime.datetime] = None\n    ```\n\n5. Create new `Ketchup/ketchup/base.py` file with the following contents for configuration.\n\n    ```python\n    import os\n    import typing\n\n\n    class Config:\n        DB_URI: str = \"postgresql+asyncpg://localhost/ketchup\"\n\n        def __init__(self, prefix: str = \"KETCHUP_\"):\n            for name, type_ in typing.get_type_hints(self).items():\n                envname = prefix + name\n                if envname in os.environ:\n                    setattr(self, name, type_(os.environ[envname]))\n\n\n    config = Config()\n    ```\n\n6. At this point, make sure PostgreSQL has beeng configured properly with an empty\ndatabase setup and referenced by either `config.DB_URI` or by setting os env variable\n`KETCHUP_DB_URI`.  Once that's been done, use *Alembic* to auto-generate the first revision migration file.\n\n    ```sh\n    poetry run alembic revision --autogenerate -m \"New ketchup_todos table\"\n    ```\n\n7. Run the following to setup the necessary database schema.\n\n    ```sh\n    poetry run alembic upgrade head\n    ```\n\n### Part 3b: Making the GraphQL schema useful\n\n1. Modify `Ketchup/ketchup/gqlschema.py` to have basic CRUD access via *Query* and *Mutation*.\n\n    ```python\n    import datetime\n    import typing\n\n    import strawberry\n    from sqlalchemy.future import select\n\n    from ketchup import sqlamodels\n\n    strawberry.type(sqlamodels.Todo)\n\n\n    @strawberry.type\n    class Query:\n        todos: list[sqlamodels.Todo]\n\n        @strawberry.field(name=\"todos\")\n        async def _todos_resolver(self) -\u003e list[sqlamodels.Todo]:\n            async with sqlamodels.atomic_session() as session:\n                items = (await session.execute(select(sqlamodels.Todo))).scalars()\n            todos = typing.cast(list[sqlamodels.Todo], items)\n            return todos\n\n\n    @strawberry.type(description=\"Standard CRUD operations for todo's\")\n    class TodoOps:\n        @strawberry.mutation\n        async def add_todo(self, text: str) -\u003e sqlamodels.Todo:\n            todo = sqlamodels.Todo(text=text)\n            async with sqlamodels.atomic_session() as session:\n                session.add(todo)\n            return todo\n\n        @strawberry.mutation\n        async def remove_todo(self, id: int) -\u003e bool:\n            async with sqlamodels.atomic_session() as session:\n                item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()\n                todo = typing.cast(typing.Optional[sqlamodels.Todo], item)\n\n                if todo is None:\n                    return False\n\n                await session.delete(todo)\n            return True\n\n        @strawberry.mutation\n        async def set_todo_completed(self, id: int, flag: bool = True) -\u003e typing.Optional[sqlamodels.Todo]:\n            async with sqlamodels.atomic_session() as session:\n                item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()\n                todo = typing.cast(typing.Optional[sqlamodels.Todo], item)\n\n                if todo is None:\n                    return None\n\n                todo.completed = datetime.datetime.now(datetime.timezone.utc) if flag else None\n            return todo\n\n        @strawberry.mutation\n        async def modify_todo_text(self, id: int, text: str) -\u003e typing.Optional[sqlamodels.Todo]:\n            async with sqlamodels.atomic_session() as session:\n                item = (await session.execute(select(sqlamodels.Todo).where(sqlamodels.Todo.id == id))).scalars().first()\n                todo = typing.cast(typing.Optional[sqlamodels.Todo], item)\n\n                if todo is None:\n                    return None\n\n                todo.text = text\n            return todo\n\n\n    @strawberry.type\n    class Mutation:\n        todos: TodoOps\n\n        @strawberry.field(name=\"todos\")\n        def _todos_resolver(self) -\u003e TodoOps:\n            return TodoOps()\n\n\n    schema = strawberry.Schema(query=Query, mutation=Mutation)\n    ```\n\n2. The ``Ketchup/ketchup/webapp.py` file will need to be updated to accomodate the *SQLAlchemy* database access as well as\nthe new mutation support.\n\n    ```python\n    import asyncio\n\n    from hypercorn.asyncio import serve\n    from hypercorn.config import Config as HypercornConfig\n    from quart import Quart\n\n    from ketchup import base\n    from ketchup.gqlschema import schema\n    from ketchup.strawview import GraphQLView\n\n    app = Quart(\"ketchup\")\n    app.config.from_object(base.config)\n\n\n    app.add_url_rule(\"/graphql\", view_func=GraphQLView.as_view(\"graphql_view\", schema=schema))\n\n\n    @app.route(\"/\")\n    async def index():\n        return 'Welcome to Ketchup!  Please see \u003ca href=\"/graphql\"\u003eGraph\u003cem\u003ei\u003c/em\u003eQL\u003c/a\u003e to interact with the GraphQL endpoint.'\n\n\n    def hypercorn_serve():\n        hypercorn_config = HypercornConfig()\n        hypercorn_config.bind = [\"0.0.0.0:5000\"]\n        hypercorn_config.use_reloader = True\n        asyncio.run(serve(app, hypercorn_config, shutdown_trigger=lambda: asyncio.Future()))\n\n\n    if __name__ == \"__main__\":\n        hypercorn_serve()\n    ```\n\n3. At this point it should be possible to restart the web application and start playing with the actual\ngraphql queries.\n\n    ```sh\n    poetry run python -m ketchup.webapp\n    ```\n\n    And the go to \u003chttp://localhost:5000/graphql\u003e to test queries/mutations.  Some examples are:\n\n    ```graphql\n    # Query #1\n    # The following will show all todos persisted to the database .. upon first query it should return an empty\n    # result set\n    query {\n      todos {\n        id\n        text\n        created\n        completed\n      }\n    }\n    ```\n\n    ```graphql\n    # Mutation #1\n    # This will create our first todo record and show ups the generated ID.  After running this at least once\n    # it should be possible to re-run \"Query #1\" above and see data that was created and saved.\n    mutation {\n      todos {\n        addTodo(text: \"Hello World\") {\n          id\n        }\n      }\n    }\n\n    ```\n\n### Bonus Points - Adding Query Tests\n\n1. Add new `Ketchup/tests/conftest.py` file to setup *pytest* fixtures.\n\n    ```python\n    import asyncio\n    import uuid\n    from urllib.parse import urlparse, urlunparse\n\n    import pytest\n    from sqlalchemy import pool, text\n    from sqlalchemy.ext.asyncio import (\n        AsyncSession,\n        async_scoped_session,\n        create_async_engine,\n    )\n    from sqlalchemy.orm import close_all_sessions, sessionmaker\n\n    from ketchup import __version__, base, sqlamodels\n\n\n    def test_version():\n        assert __version__ == \"0.1.0\"\n\n\n    @pytest.fixture(scope=\"session\")\n    def event_loop():\n        loop = asyncio.get_event_loop_policy().new_event_loop()\n        yield loop\n        loop.close()\n\n\n    @pytest.fixture(scope=\"session\")\n    def session_monkeypatch():\n        mpatch = pytest.MonkeyPatch()\n        yield mpatch\n        mpatch.undo()\n\n\n    @pytest.fixture(scope=\"session\")\n    async def empty_db(session_monkeypatch: pytest.MonkeyPatch):\n        parsed = urlparse(base.config.DB_URI)\n        dbname = parsed.path[1:] + \"_test_\" + uuid.uuid4().hex\n\n        newuri = urlunparse([parsed[0], parsed[1], \"/template1\", parsed[3], parsed[4], parsed[5]])\n\n        anony_engine = create_async_engine(\n            newuri,\n            future=True,\n            poolclass=pool.NullPool,\n            isolation_level=\"AUTOCOMMIT\",\n        )\n\n        async with anony_engine.connect() as conn:\n            await (await conn.execution_options(isolation_level=\"AUTOCOMMIT\")).execute(text(f\"CREATE DATABASE {dbname}\"))\n\n        async_engine = None\n        async_session_factory = None\n        make_session = None\n        try:\n            newuri = urlunparse([parsed[0], parsed[1], \"/\" + dbname, parsed[3], parsed[4], parsed[5]])\n            async_engine = create_async_engine(newuri, future=True)\n            async_session_factory = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)  # type: ignore - having some pyright issues\n            make_session = async_scoped_session(async_session_factory, scopefunc=asyncio.current_task)\n            session_monkeypatch.setattr(sqlamodels, \"make_session\", make_session)\n\n            async with async_engine.begin() as conn:\n                await conn.run_sync(sqlamodels.mapper_registry.metadata.create_all)\n\n            yield\n        finally:\n            close_all_sessions()\n            if async_engine is not None:\n                try:\n                    await async_engine.dispose()\n                except Exception:\n                    ...\n            async with anony_engine.connect() as conn:\n                await (await conn.execution_options(isolation_level=\"AUTOCOMMIT\")).execute(text(f\"DROP DATABASE {dbname}\"))\n    ```\n\n2. Update the `Ketchup/tests/test_ketchup.py` file to have the following content.\n\n    ```python\n    import pytest\n\n    from ketchup import __version__, gqlschema, webapp\n\n\n    def test_version():\n        assert __version__ == \"0.1.0\"\n\n\n    @pytest.mark.asyncio\n    class TestViews:\n        async def test_index(self):\n            assert \"Welcome\" in (await webapp.index())\n\n\n    @pytest.mark.asyncio\n    @pytest.mark.usefixtures(\"empty_db\")\n    class TestGraphQuery:\n        async def _create(self, text: str) -\u003e int:\n            result = await gqlschema.schema.execute('mutation { todos { addTodo(text: \"hello world\") { id } } }')\n            assert result.data is not None\n            assert \"id\" in result.data[\"todos\"][\"addTodo\"]\n\n            newid = result.data[\"todos\"][\"addTodo\"][\"id\"]\n            return newid\n\n        async def test_create_remove(self):\n            newid = await self._create(\"hello world\")\n            result = await gqlschema.schema.execute(\"mutation { todos { removeTodo(id: %s) } }\" % newid)\n            assert result.data is not None\n            assert result.data[\"todos\"][\"removeTodo\"] == True\n    ```\n\n3. Run the tests by issuing the following from *inside* the `Ketchup` directory.\n\n    ```sh\n    poetry run pytest\n    ```\n\n    The result should be something like:\n\n    ```text\n    =============================== test session starts ===============================\n    platform linux -- Python 3.9.5, pytest-5.4.3, py-1.10.0, pluggy-0.13.1\n    rootdir: /home/ubuntu/dev/Ketchup\n    plugins: anyio-3.3.1, asyncio-0.15.1\n    collected 3 items\n\n    tests/test_ketchup.py ...                                                   [100%]\n\n    ================================ 3 passed in 0.36s ================================\n    ```\n\n## Frameworks/Components Reference\n\n[Quart](https://pgjones.gitlab.io/quart/index.html)\n: An asynchronous I/O (asyncio) based web framework inspired by Flask\n\n[Strawberry GraphQL](https://strawberry.rocks/docs/integrations/asgi)\n: A GraphQL library for Python enabling the building of GraphQL web apps using Python dataclasses\n\n[SQLAlchemy](https://www.sqlalchemy.org/)\n: Python ORM mapper (with `asyncio` support as of v1.4).\n\n[asyncpg](https://github.com/MagicStack/asyncpg)\n: A PostgreSQL client library for Python using `asyncio`.\n\n[Alembic](https://alembic.sqlalchemy.org/en/latest/)\n: A database migration tool for *SQLAlchemy*.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frockyburt%2Fketchup","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frockyburt%2Fketchup","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frockyburt%2Fketchup/lists"}