From 807cb7098f12592de3ce935444a8e05e880c17f2 Mon Sep 17 00:00:00 2001 From: Hraban Luyat Date: Sun, 29 Dec 2024 00:15:12 -0500 Subject: [PATCH] release: v0.0.5: async and integration tests --- .git-blame-ignore-revs | 2 + .github/workflows/ci.yaml | 23 +- README.md | 10 +- brrr-demo.module.nix | 52 +++ brrr-demo.options.nix | 43 +++ brrr-demo.service.nix | 20 +- brrr-demo.test.nix | 114 +++++++ brrr_demo.py | 225 +++++++++---- dynamodb.module.nix | 40 +++ flake.nix | 13 +- pyproject.toml | 15 +- src/brrr/backends/dynamo.py | 80 ++--- src/brrr/backends/in_memory.py | 32 +- src/brrr/backends/redis.py | 196 ++++------- src/brrr/backends/sqs.py | 54 ---- src/brrr/brrr.py | 215 +++++-------- src/brrr/compat.py | 48 --- src/brrr/queue.py | 18 +- src/brrr/store.py | 124 ++++--- tests/test_queue.py | 100 +++--- tests/test_store.py | 178 +++++----- uv.lock | 572 ++++++++++++++++++++++++++------- 22 files changed, 1334 insertions(+), 840 deletions(-) create mode 100644 .git-blame-ignore-revs create mode 100644 brrr-demo.module.nix create mode 100644 brrr-demo.options.nix create mode 100644 brrr-demo.test.nix create mode 100644 dynamodb.module.nix delete mode 100644 src/brrr/backends/sqs.py delete mode 100644 src/brrr/compat.py diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..32d17f2 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# ruff format +706580fbb393467063c6a372a902172203bfd382 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3bfc0cd..cb35599 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ on: pull_request: push: -name: Syntax +name: CI jobs: nocommit: runs-on: ubuntu-latest @@ -11,13 +11,26 @@ jobs: - name: "nocommit checker" uses: nobssoftware/nocommit@v2 - check: + nix-flake-check: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v9 + - uses: cachix/install-nix-action@v30 + with: + enable_kvm: true + extra_nix_config: "system-features = nixos-test benchmark big-parallel kvm" - name: Configure Nix cache uses: DeterminateSystems/magic-nix-cache-action@main - - run: nix flake check + - run: nix flake check -L + + # Ensure nix flake show isn’t broken + nix-flake-show: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v9 + # No need for a cache + - run: nix flake show diff --git a/README.md b/README.md index 1197c00..35dc150 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,20 @@ Highlights: from brrr import task @task -def fib(n: int, salt=None): +async def fib(n: int, salt=None): match n: case 0: return 0 case 1: return 1 - case _: return sum(fib.map([[n - 2, salt], [n - 1, salt]])) + case _: return sum(await fib.map([[n - 2, salt], [n - 1, salt]])) @task -def fib_and_print(n: str): - f = fib(int(n)) +async def fib_and_print(n: str): + f = await fib(int(n)) print(f"fib({n}) = {f}", flush=True) return f @task -def hello(greetee: str): +async def hello(greetee: str): greeting = f"Hello, {greetee}!" print(greeting, flush=True) return greeting diff --git a/brrr-demo.module.nix b/brrr-demo.module.nix new file mode 100644 index 0000000..0f69e91 --- /dev/null +++ b/brrr-demo.module.nix @@ -0,0 +1,52 @@ +# Copyright © 2024 Brrr Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# A NixOS module for a brrr demo binary. N.B.: It does not contain any +# dependencies; this is just the demo script. +# +# Inspired by +# https://blakesmith.me/2024/03/02/running-nixos-tests-with-flakes.html + +{ lib, config, pkgs, ... }: { + options.services.brrr-demo = let + native = import ./brrr-demo.options.nix { inherit lib pkgs; }; + mod1 = { options = native; }; + mod2 = { + options.enable = lib.mkEnableOption "brrr-demo"; + }; + in lib.mkOption { + description = "Brrr demo service configuration"; + type = lib.types.submoduleWith { + modules = [ mod1 mod2 ]; + }; + default = {}; + }; + + config = let + cfg = config.services.brrr-demo; + in lib.mkIf cfg.enable { + + systemd.services.brrr-demo = { + inherit (cfg) environment; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + script = '' + exec ${lib.getExe cfg.package} ${lib.escapeShellArgs cfg.args} + ''; + serviceConfig = { + Type = "simple"; + }; + }; + }; +} diff --git a/brrr-demo.options.nix b/brrr-demo.options.nix new file mode 100644 index 0000000..40e523d --- /dev/null +++ b/brrr-demo.options.nix @@ -0,0 +1,43 @@ +# Copyright © 2024 Brrr Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Module options for any brrr-demo service. These are shared between the actual +# NixOS module and the services-flake module. N.B.: pkgs must have a brrr-demo +# derivation available, from an overlay. +# +# I think I’m still missing one layer of submodule use here--these are just the +# raw options for the caller to directly import, but I’m sure there’s some way +# to use submodule for this instead? Maybe... deferred? This works for now but +# TBD. + +{ lib, pkgs }: + +with lib.types; { + # You’ll want to override this unless you use an overlay + package = lib.mkPackageOption pkgs "brrr-demo" { }; + args = lib.mkOption { + default = []; + type = listOf str; + }; + environment = lib.mkOption { + type = types.attrsOf types.str; + default = { }; + example = { + AWS_ENDPOINT_URL = "http://localhost:12345"; + }; + description = '' + Extra environment variables passed to the `brrr-demo` process. + ''; + }; +} diff --git a/brrr-demo.service.nix b/brrr-demo.service.nix index 792894a..3851ea4 100644 --- a/brrr-demo.service.nix +++ b/brrr-demo.service.nix @@ -17,24 +17,8 @@ # Ok. { config, pkgs, name, lib, ... }: { - options = with lib.types; { - # You’ll want to override this unless you use an overlay - package = lib.mkPackageOption pkgs "brrr-demo" { }; - args = lib.mkOption { - default = []; - type = listOf str; - }; - environment = lib.mkOption { - type = types.attrsOf types.str; - default = { }; - example = { - AWS_ENDPOINT_URL = "http://localhost:12345"; - }; - description = '' - Extra environment variables passed to the `brrr-demo` process. - ''; - }; - }; + # services-flake requires setting the options top-level + options = import ./brrr-demo.options.nix { inherit lib pkgs; }; config = { outputs.settings.processes.${name} = { environment = config.environment; diff --git a/brrr-demo.test.nix b/brrr-demo.test.nix new file mode 100644 index 0000000..3921cfc --- /dev/null +++ b/brrr-demo.test.nix @@ -0,0 +1,114 @@ +# Copyright © 2024 Brrr Authors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Inspired by +# https://blakesmith.me/2024/03/02/running-nixos-tests-with-flakes.html + +{ self, pkgs }: + +# Distributed test across multiple VMs, so there’s still some room for bugs to +# creep into the actual demo. Both are nice to have so we should probably add a +# test that replicates the actual demo as closely as possible to catch any +# errors there. +pkgs.testers.runNixOSTest { + name = "brrr-test"; + + nodes.datastores = { config, pkgs, ... }: { + imports = [ + # Not going to export and dogfood this--it’s just local only + ./dynamodb.module.nix + ]; + services.redis.servers.main = { + enable = true; + port = 6379; + openFirewall = true; + bind = null; + logLevel = "debug"; + settings.protected-mode = "no"; + }; + services.dynamodb = { + enable = true; + openFirewall = true; + }; + }; + nodes.server = { config, pkgs, ... }: { + imports = [ + self.nixosModules.brrr-demo + ]; + networking.firewall.allowedTCPPorts = [ 8080 ]; + services.brrr-demo = { + enable = true; + package = self.packages.${pkgs.system}.brrr-demo; + args = [ "server" ]; + environment = { + BRRR_DEMO_LISTEN_HOST = "0.0.0.0"; + BRRR_DEMO_REDIS_URL = "redis://datastores:6379"; + AWS_DEFAULT_REGION = "foo"; + AWS_ENDPOINT_URL = "http://datastores:8000"; + AWS_ACCESS_KEY_ID = "foo"; + AWS_SECRET_ACCESS_KEY = "bar"; + }; + }; + }; + nodes.worker = { config, pkgs, ... }: { + imports = [ + self.nixosModules.brrr-demo + ]; + services.brrr-demo = { + enable = true; + package = self.packages.${pkgs.system}.brrr-demo; + args = [ "worker" ]; + environment = { + BRRR_DEMO_REDIS_URL = "redis://datastores:6379"; + AWS_DEFAULT_REGION = "foo"; + AWS_ENDPOINT_URL = "http://datastores:8000"; + AWS_ACCESS_KEY_ID = "foo"; + AWS_SECRET_ACCESS_KEY = "bar"; + }; + }; + }; + # Separate node entirely just for the actual testing + nodes.tester = { config, pkgs, ... }: let + test-script = pkgs.writeShellApplication { + name = "test-brrr-demo"; + # 😂 + text = '' + eval "$(curl --fail -sSL "http://server:8080/hello?greetee=Jim" | jq '. == {status: "ok", result: "Hello, Jim!"}')" + eval "$(curl --fail -sSL "http://server:8080/fib_and_print?n=6&salt=abcd" | jq '. == {status: "ok", result: 8}')" + ''; + }; + in { + environment.systemPackages = [ + test-script + ] ++ (with pkgs; [ + curl + jq + ]); + }; + + globalTimeout = 2 * 60; + + testScript = '' + # Start first because it's a dependency + datastores.wait_for_unit("default.target") + # Server initializes the stores + server.wait_for_unit("default.target") + worker.wait_for_unit("default.target") + tester.wait_for_unit("default.target") + server.wait_for_open_port(8080) + tester.wait_until_succeeds("curl --fail -sSL -X POST 'http://server:8080/hello?greetee=Jim'") + tester.wait_until_succeeds("curl --fail -sSL -X POST 'http://server:8080/fib_and_print?n=6&salt=abcd'") + tester.wait_until_succeeds("test-brrr-demo") + ''; +} diff --git a/brrr_demo.py b/brrr_demo.py index 8c025f9..2a1c7a2 100755 --- a/brrr_demo.py +++ b/brrr_demo.py @@ -1,84 +1,170 @@ #!/usr/bin/env python3 +import asyncio +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +import logging +import logging.config +import json import os +from pprint import pprint import sys from typing import Iterable -import time -import boto3 -import redis -import bottle +import aioboto3 +from aiohttp import web +import redis.asyncio as redis +from types_aiobotocore_dynamodb import DynamoDBClient -from brrr.backends import redis as redis_, dynamo +from brrr.backends.redis import RedisStream +from brrr.backends.dynamo import DynamoDbMemStore import brrr from brrr import task -@bottle.route("/") -def get_or_schedule_task(task_name: str): +logger = logging.getLogger(__name__) +routes = web.RouteTableDef() + + +def table_name() -> str: """ - GET /task_name?argv={"..."} + Get table name from environment """ - kwargs = dict(bottle.request.query.items()) + return os.environ.get("DYNAMODB_TABLE_NAME", "brrr") + + +def response(status: int, content: dict): + return web.Response(status=status, text=json.dumps(content)) + +@routes.get("/{task_name}") +async def get_task_result(request: web.BaseRequest): + # aiohttp uses a multidict but we don’t need that for this demo. + kwargs = dict(request.query) + + task_name = request.match_info["task_name"] if task_name not in brrr.tasks: - bottle.response.status = 404 - return {"error": "No such task"} + return response(404, {"error": "No such task"}) try: - bottle.response.status = 200 - return {"status": "ok", "result": brrr.read(task_name, (), kwargs)} + result = await brrr.read(task_name, (), kwargs) except KeyError: - bottle.response.status = 202 - brrr.schedule(task_name, (), kwargs) - return {"status": "accepted"} + return response(404, dict(error="No result for this task")) + return response(200, dict(status="ok", result=result)) + + +@routes.post("/{task_name}") +async def schedule_task(request: web.BaseRequest): + kwargs = dict(request.query) + + task_name = request.match_info["task_name"] + if task_name not in brrr.tasks: + return response(404, {"error": "No such task"}) + + await brrr.schedule(task_name, (), kwargs) + return response(202, {"status": "accepted"}) + + +# ... where is the python contextmanager monad? + + +@asynccontextmanager +async def with_redis() -> AsyncIterator[redis.Redis]: + redurl = os.environ.get("BRRR_DEMO_REDIS_URL") + rkwargs = dict( + decode_responses=True, + health_check_interval=10, + socket_connect_timeout=5, + retry_on_timeout=True, + socket_keepalive=True, + protocol=3, + ) + if redurl is None: + rc = redis.Redis(**rkwargs) + else: + rc = redis.from_url(redurl, **rkwargs) + await rc.ping() + try: + yield rc + finally: + await rc.aclose() + + +@asynccontextmanager +async def with_resources() -> AsyncIterator[tuple[redis.Redis, DynamoDBClient]]: + async with with_redis() as rc: + async with aioboto3.Session().client("dynamodb") as dync: + dync: DynamoDBClient + yield (rc, dync) -def init_brrr(reset_backends): - redis_client = redis.Redis(decode_responses=True) - queue = redis_.RedisStream(redis_client, os.environ.get("REDIS_QUEUE_KEY", "r1")) - if reset_backends: - queue.setup() - dynamo_client = boto3.client("dynamodb") - store = dynamo.DynamoDbMemStore(dynamo_client, os.environ.get("DYNAMODB_TABLE_NAME", "brrr")) - if reset_backends: - store.create_table() +@asynccontextmanager +async def with_brrr_wrap() -> AsyncIterator[tuple[RedisStream, DynamoDbMemStore]]: + async with with_resources() as (rc, dync): + store = DynamoDbMemStore(dync, table_name()) + queue = RedisStream(rc, os.environ.get("REDIS_QUEUE_KEY", "r1")) + yield (queue, store) + + +@asynccontextmanager +async def with_brrr(reset_backends): + async with with_brrr_wrap() as (queue, store): + if reset_backends: + await queue.setup() + await store.create_table() + brrr.setup(queue, store) + yield - brrr.setup(queue, store) @task -def fib(n: int, salt=None): +async def fib(n: int, salt=None): match n: case 0 | 1: return n case _: - return sum(fib.map([[n - 2, salt], [n - 1, salt]])) + return sum(await fib.map([[n - 2, salt], [n - 1, salt]])) + @task -def fib_and_print(n: str, salt = None): - f = fib(int(n), salt) +async def fib_and_print(n: str, salt=None): + f = await fib(int(n), salt) print(f"fib({n}) = {f}", flush=True) return f + @task -def hello(greetee: str): +async def hello(greetee: str): greeting = f"Hello, {greetee}!" print(greeting, flush=True) return greeting + cmds = {} + + def cmd(f): cmds[f.__name__] = f return f + @cmd -def worker(): - init_brrr(False) - brrr.wrrrk(1) +async def worker(): + async with with_brrr(False): + await brrr.wrrrk() + @cmd -def server(): - init_brrr(True) - bottle.run(host="localhost", port=8333) +async def server(): + bind_addr = os.environ.get("BRRR_DEMO_LISTEN_HOST", "127.0.0.1") + bind_port = int(os.environ.get("BRRR_DEMO_LISTEN_PORT", "8080")) + async with with_brrr(True): + app = web.Application() + app.add_routes(routes) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, bind_addr, bind_port) + await site.start() + logger.info(f"Listening on http://{bind_addr}:{bind_port}") + await asyncio.Event().wait() def args2dict(args: Iterable[str]) -> dict[str, str]: @@ -90,47 +176,60 @@ def args2dict(args: Iterable[str]) -> dict[str, str]: """ it = iter(args) - return {k.lstrip('-'): v for k, v in zip(it, it)} + return {k.lstrip("-"): v for k, v in zip(it, it)} + @cmd -def schedule(job: str, *args: str): +async def schedule(job: str, *args: str): """ Put a single job onto the queue """ - init_brrr(False) - brrr.schedule(job, (), args2dict(args)) + async with with_brrr(False): + await brrr.schedule(job, (), args2dict(args)) -@cmd -def monitor(): - init_brrr(False) - redis_client = redis.Redis() - queue = redis_.RedisStream(redis_client, os.environ.get("REDIS_QUEUE_KEY", "r1")) - while True: - print(queue.get_info()) - time.sleep(1) @cmd -def reset(): - table_name = os.environ.get("DYNAMODB_TABLE_NAME", "brrr") - dynamo_client = boto3.client("dynamodb") - try: - dynamo_client.delete_table(TableName=table_name) - except Exception as e: - # Table does not exist - if "ResourceNotFoundException" not in str(e): - raise +async def monitor(): + async with with_brrr_wrap() as (queue, _): + while True: + pprint(await queue.get_info()) + await asyncio.sleep(1) - redis_client = redis.Redis() - redis_client.flushall() - init_brrr(True) -def main(): +@cmd +async def reset(): + async with with_resources() as (rc, dync): + try: + await dync.delete_table(TableName=table_name()) + except Exception as e: + # Table does not exist + if "ResourceNotFoundException" not in str(e): + raise + + await rc.flushall() + + +async def amain(): + # To log _all_ messages at DEBGUG level (very noisy) + # logging.basicConfig(level=logging.DEBUG) + logging.basicConfig() + logger.setLevel(logging.DEBUG) + # To log all brrr messages at DEBUG level (quite noisy) + # logging.getLogger('brrr').setLevel(logging.DEBUG) f = cmds.get(sys.argv[1]) if len(sys.argv) > 1 else None if f: - f(*sys.argv[2:]) + await f(*sys.argv[2:]) else: print(f"Usage: brrr_demo.py <{" | ".join(cmds.keys())}>") sys.exit(1) + +def main(): + try: + asyncio.run(amain()) + except KeyboardInterrupt: + pass + + if __name__ == "__main__": main() diff --git a/dynamodb.module.nix b/dynamodb.module.nix new file mode 100644 index 0000000..a4b06e7 --- /dev/null +++ b/dynamodb.module.nix @@ -0,0 +1,40 @@ +# Copyright © Brrr Authors +# +# Licensed under AGPLv3-only. See README for year and details. + +# Dynamodb module for NixOS. No frills just local ephemeral host. + +{ lib, pkgs, config, ... }: { + options.services.dynamodb = { + enable = lib.mkEnableOption "dynamodb"; + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + }; + }; + config = let + cfg = config.services.dynamodb; + in lib.mkIf cfg.enable { + systemd.services.dynamodb = { + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + script = '' + exec ${lib.getExe pkgs.dynamodb-local} -dbPath /var/lib/dynamodb + ''; + serviceConfig = { + Type = "simple"; + User = "dynamodb"; + Group = "dynamodb"; + }; + }; + users.users.dynamodb = { + group = "dynamodb"; + home = "/var/lib/dynamodb"; + useDefaultShell = true; + isSystemUser = true; + createHome = true; + }; + users.groups.dynamodb = {}; + networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ 8000 ]; + }; +} diff --git a/flake.nix b/flake.nix index b78086a..cb41048 100644 --- a/flake.nix +++ b/flake.nix @@ -64,6 +64,7 @@ ]; services = let demoEnv = { + AWS_DEFAULT_REGION = "us-east-1"; AWS_ENDPOINT_URL = "http://localhost:8000"; AWS_ACCESS_KEY_ID = "000000000000"; AWS_SECRET_ACCESS_KEY = "fake"; @@ -84,6 +85,10 @@ }; }; }; + # WIP, exporting is best effort. + nixosModules = { + brrr-demo = import ./brrr-demo.module.nix; + }; }; perSystem = { config, self', inputs', pkgs, lib, system, ... }: let uvWorkspace = inputs.uv2nix.lib.workspace.loadWorkspace { @@ -113,6 +118,7 @@ inputs.services-flake.processComposeModules.default self.processComposeModules.default ]; + cli.options.no-server = true; services.brrr-demo.server.enable = true; services.brrr-demo.worker.enable = true; }; @@ -121,6 +127,7 @@ inputs.services-flake.processComposeModules.default self.processComposeModules.default ]; + cli.options.no-server = true; services.brrr-demo.server.enable = false; services.brrr-demo.worker.enable = false; }; @@ -178,14 +185,15 @@ name = "ruff"; nativeBuildInputs = [ self'.packages.dev ]; src = lib.cleanSource ./.; - # Don’t check tests for now though we should buildPhase = '' - ruff check src + ruff check + ruff format --check ''; installPhase = '' touch $out ''; }; + demoNixosTest = pkgs.callPackage ./brrr-demo.test.nix { inherit self; }; }; devshells = { impure = { @@ -231,6 +239,7 @@ self'.packages.uv self'.packages.brrr-demo virtualenv + pkgs.redis # For the CLI ]; commands = [ # Always build aarch64-linux diff --git a/pyproject.toml b/pyproject.toml index f2c806c..5885018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brrr" -version = "0.0.4" +version = "0.0.5" description = "Horizontally scalable workflow scheduling with pluggable backends" authors = [ {name = "Hraban Luyat", email = "hraban@0brg.net"}, @@ -10,17 +10,24 @@ readme = "README.md" requires-python = "<4,>=3.12" dependencies = [] +[project.urls] +homepage = "https://github.com/nobssoftware/brrr" + [dependency-groups] dev = [ - "boto3>=1.35.71", - "boto3-stubs[essential]>=1.35.71", "pyright>=1.1.389", "redis>=5.2.0", "ruff>=0.8.1", "pytest>=8.3.4", - "bottle>=0.13.2", + "aioboto3>=13.3.0", + "types-aioboto3[essential]>=13.3.0.post1", + "pytest-asyncio>=0.25.0", + "aiohttp>=3.11.11", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/src/brrr/backends/dynamo.py b/src/brrr/backends/dynamo.py index e5901ed..458ea5b 100644 --- a/src/brrr/backends/dynamo.py +++ b/src/brrr/backends/dynamo.py @@ -1,11 +1,16 @@ from __future__ import annotations +import logging import typing from ..store import CompareMismatch, MemKey, Store if typing.TYPE_CHECKING: - from mypy_boto3_dynamodb import DynamoDBClient + from types_aiobotocore_dynamodb import DynamoDBClient + + +logger = logging.getLogger(__name__) + # The frame table layout is: # @@ -32,56 +37,50 @@ class DynamoDbMemStore(Store): table_name: str def key(self, mem_key: MemKey) -> dict: - return { - "pk": {"S": mem_key.id}, - "sk": {"S": mem_key.type} - } + return {"pk": {"S": mem_key.id}, "sk": {"S": mem_key.type}} def __init__(self, client: DynamoDBClient, table_name: str): self.client = client self.table_name = table_name - def __contains__(self, key: MemKey): - return "Item" in self.client.get_item( + async def has(self, key: MemKey): + return "Item" in await self.client.get_item( TableName=self.table_name, Key=self.key(key), ) - def __getitem__(self, key: MemKey) -> bytes: - response = self.client.get_item( + async def get(self, key: MemKey) -> bytes: + response = await self.client.get_item( TableName=self.table_name, Key=self.key(key), ) if "Item" not in response: + logger.debug(f"getting key: {key}: not found") raise KeyError(key) + logger.debug(f"getting key: {key}: found") return response["Item"]["value"]["B"] - - def __setitem__(self, key: MemKey, value: bytes): - self.client.put_item( - TableName=self.table_name, - Item={ - **self.key(key), - "value": {"B": value} - } + async def set(self, key: MemKey, value: bytes): + await self.client.put_item( + TableName=self.table_name, Item={**self.key(key), "value": {"B": value}} ) - def __delitem__(self, key: MemKey): - self.client.delete_item( + async def delete(self, key: MemKey): + await self.client.delete_item( TableName=self.table_name, Key=self.key(key), ) - def compare_and_set(self, key: MemKey, value: bytes, expected: bytes | None): - ExpressionAttributeValues={":value": {"B": value}} + async def compare_and_set(self, key: MemKey, value: bytes, expected: bytes | None): + ExpressionAttributeValues = {":value": {"B": value}} if expected is None: - ConditionExpression="attribute_not_exists(#value)" + ConditionExpression = "attribute_not_exists(#value)" else: ExpressionAttributeValues[":expected"] = {"B": expected} - ConditionExpression="#value = :expected" + ConditionExpression = "#value = :expected" try: - self.client.update_item( + await self.client.update_item( TableName=self.table_name, Key=self.key(key), UpdateExpression="SET #value = :value", @@ -92,9 +91,9 @@ def compare_and_set(self, key: MemKey, value: bytes, expected: bytes | None): except self.client.exceptions.ConditionalCheckFailedException: raise CompareMismatch - def compare_and_delete(self, key: MemKey, expected: bytes): + async def compare_and_delete(self, key: MemKey, expected: bytes): try: - self.client.delete_item( + await self.client.delete_item( TableName=self.table_name, Key=self.key(key), ConditionExpression="attribute_exists(#value) AND #value = :expected", @@ -105,35 +104,20 @@ def compare_and_delete(self, key: MemKey, expected: bytes): except self.client.exceptions.ConditionalCheckFailedException: raise CompareMismatch - def create_table(self): + async def create_table(self): try: - self.client.create_table( + await self.client.create_table( TableName=self.table_name, KeySchema=[ - { - "AttributeName": "pk", - "KeyType": "HASH" - }, - { - "AttributeName": "sk", - "KeyType": "RANGE" - } + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, ], AttributeDefinitions=[ - { - "AttributeName": "pk", - "AttributeType": "S" - }, - { - "AttributeName": "sk", - "AttributeType": "S" - } + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, ], # TODO make this configurable? Should this method even exist? - ProvisionedThroughput={ - "ReadCapacityUnits": 5, - "WriteCapacityUnits": 5 - } + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, ) except self.client.exceptions.ResourceInUseException: pass diff --git a/src/brrr/backends/in_memory.py b/src/brrr/backends/in_memory.py index 5d5f3e1..05bd9f6 100644 --- a/src/brrr/backends/in_memory.py +++ b/src/brrr/backends/in_memory.py @@ -4,36 +4,35 @@ from ..queue import Queue, Message, QueueInfo, QueueIsClosed, QueueIsEmpty from ..store import Store + class InMemoryQueue(Queue): """ This queue does not do receipts """ + messages = collections.deque() closed = False - def put(self, message: str): - self.messages.append(message) + async def put(self, body: str): + self.messages.append(body) - def get_message(self) -> Message: + async def get_message(self) -> Message: if self.closed: raise QueueIsClosed if not self.messages: raise QueueIsEmpty - return Message(self.messages.popleft(), '') + return Message(self.messages.popleft(), "") - async def get_message_async(self): - return self.get_message() - - def delete_message(self, receipt_handle: str): + async def delete_message(self, receipt_handle: str): pass - def get_info(self): + async def get_info(self): return QueueInfo( num_messages=len(self.messages), num_inflight_messages=0, ) - def set_message_timeout(self, receipt_handle, seconds): + async def set_message_timeout(self, receipt_handle, seconds): pass @@ -42,32 +41,33 @@ class InMemoryByteStore(Store[bytes]): """ A store that stores bytes """ + store: dict def __init__(self): self.store = {} - def __contains__(self, key: str) -> bool: + async def has(self, key: str) -> bool: return key in self.store - def __getitem__(self, key: str) -> bytes: + async def get(self, key: str) -> bytes: return self.store[key] - def __setitem__(self, key: str, value: bytes): + async def set(self, key: str, value: bytes): self.store[key] = value - def __delitem__(self, key: str): + async def delete(self, key: str): try: del self.store[key] except KeyError: pass - def compare_and_set(self, key: str, value: bytes, expected: bytes | None): + async def compare_and_set(self, key: str, value: bytes, expected: bytes | None): if expected is None and key in self.store or self.store.get(key) != expected: raise CompareMismatch self.store[key] = value - def compare_and_delete(self, key: str, expected: bytes | None): + async def compare_and_delete(self, key: str, expected: bytes | None): if expected is None and key in self.store or self.store.get(key) != expected: raise CompareMismatch del self.store[key] diff --git a/src/brrr/backends/redis.py b/src/brrr/backends/redis.py index c0ccc08..31baaf3 100644 --- a/src/brrr/backends/redis.py +++ b/src/brrr/backends/redis.py @@ -1,11 +1,10 @@ from __future__ import annotations -import time +import asyncio +import logging import typing -from typing import Any -from ..queue import Message, Queue, QueueInfo, RichQueue, QueueIsEmpty -from ..store import CompareMismatch, MemKey, Store +from ..queue import Message, QueueInfo, RichQueue, QueueIsEmpty COMPARE_AND_SET_SCRIPT = """ local key = KEYS[1] @@ -36,7 +35,7 @@ """ if typing.TYPE_CHECKING: - import redis + from redis.asyncio import Redis # This script takes care of all of the queue semantics beyond at least once delivery # - Rate limiting @@ -62,30 +61,6 @@ local max_concurrency = tonumber(ARGV[5]) local job_timeout_ms = tonumber(ARGV[6]) --- Before dequeueing, restore all stuck jobs to the stream --- TODO do we want to set timeout per job? --- Count of 100 is the default, presumably we want this to be 'inf' ---local stuck_jobs = redis.call('XAUTOCLAIM', stream, group, 'watchdog', job_timeout_ms, '0-0', 'COUNT', 100) ---for _, job in ipairs(stuck_jobs[2] or {}) do - ---- Add before removing to avoid race conditions - --redis.call('XADD', stream, '*', 'body', job[2][2]) - --redis.call('SREM', rate_limiters, job[1]) ---end - --- The PEL holds all currently active jobs, grabbed by a consumer that haven't been acked --- If the PEL is full, we can't take any more jobs, we do need to make sure that --- jobs don't get stuck in the PEL forever ---if (tonumber(redis.call('XPENDING', stream, group)[1]) or 0) >= max_concurrency then - --return nil ---end - --- Rate limits are implemented by adding each grabbed task to a set of rate limiters, with a TTL --- Calculated by the rate limit pool capacity and the replenish rate --- If the rate limiter set is full, we can't take any more jobs ---if (tonumber(redis.call('SCARD', rate_limiters)) or 0) >= rate_limit_pool_capacity then - --return nil ---end - -- Grab one message from the stream local message = redis.call('XREADGROUP', 'GROUP', group, consumer, 'COUNT', 1, 'STREAMS', stream, '>') @@ -106,7 +81,6 @@ end return nil ---end) """.strip() ACK_FUNCTION = """ @@ -122,55 +96,11 @@ redis.call('XDEL', stream, msg_id) """ -class RedisQueue(Queue): - """ - Single-topic queue on Redis using LPOP and RPUSH - """ - client: redis.Redis - key: str - - def __init__(self, client: redis.Redis, key: str): - """ - Bring your own sync Redis client. - - The redis client must be initialized with `decode=True`. - """ - self.client = client - self.key = key - - def put(self, message: str): - self.client.rpush(self.key, message) - - def get_message(self) -> Message: - # This is not an async client - ret = typing.cast(list[Any], self.client.blpop([self.key], self.recv_block_secs)) - if not ret: - raise QueueIsEmpty - - if not len(ret) >= 2: - raise Exception(f"Unexpected length of return value from BLPOP: {len(ret)}") - return Message(ret[1], '') - - def get_message_async(self): - # TODO: This is a blocking operation right now - return self.get_message() - - def delete_message(self, receipt_handle: str): - pass - - def set_message_timeout(self, receipt_handle: str): - pass - - def get_info(self): - # TODO untested - return QueueInfo( - num_messages=self.client.llen(self.key), - num_inflight_messages=0, - ) +logger = logging.getLogger(__name__) class RedisStream(RichQueue): - client: redis.Redis + client: Redis queue: str group = "workers" @@ -184,17 +114,19 @@ class RedisStream(RichQueue): lib_name = "brrr" func_name = "dequeue" - def __init__(self, client: redis.Redis, queue: str): + def __init__(self, client: Redis, queue: str): self.client = client self.queue = queue - def clear(self): - self.client.delete(self.queue) - self.client.delete(self.queue + ":rate_limiters") + async def clear(self): + await self.client.delete(self.queue) + await self.client.delete(self.queue + ":rate_limiters") - def setup(self): + async def setup(self): try: - self.client.xgroup_create(self.queue, self.group, id='0', mkstream=True) + await self.client.xgroup_create( + self.queue, self.group, id="0", mkstream=True + ) # Ideally we would want to catch ‘redis.exceptions.ResponseError’ here # instead, but currently the entire production part of the code is # dependency-free. That works because the actual redis client is @@ -205,78 +137,64 @@ def setup(self): except Exception as e: if "BUSYGROUP Consumer Group name already exists" not in str(e): raise + logger.debug(f"Creating fresh queue {self.queue}: queue already existed") + return - def put(self, body: str): - # Messages can not be added to specific groups, so we just create a stream per topic - self.client.xadd(self.queue, {'body': body}) + logger.debug(f"Created fresh queue {self.queue}") - def get_message(self) -> Message: - keys = self.queue, - argv = self.group, self.consumer, self.rate_limit_pool_capacity, self.replenish_rate_per_second, self.max_concurrency, self.job_timeout_ms - response = self.client.eval(DEQUEUE_FUNCTION, len(keys), *keys, *argv) + async def put(self, body: str): + logger.debug(f"Putting new message on {self.queue}") + # Messages can not be added to specific groups, so we just create a stream per topic + await self.client.xadd(self.queue, {"body": body}) + + async def get_message(self) -> Message: + keys = (self.queue,) + argv = map( + str, + ( + self.group, + self.consumer, + self.rate_limit_pool_capacity, + self.replenish_rate_per_second, + self.max_concurrency, + self.job_timeout_ms, + ), + ) + response = await self.client.eval(DEQUEUE_FUNCTION, len(keys), *keys, *argv) if not response: # TODO: Do not wait indiscriminately but simulate blpop. How do you # do that with a script? - time.sleep(1) + await asyncio.sleep(1) raise QueueIsEmpty body, receipt_handle = response[:2] return Message(body, receipt_handle) - def get_message_async(self): - # TODO: This is a blocking operation right now - return self.get_message() - # TODO: This has a bug; xack does not remove the message from the stream - def delete_message(self, receipt_handle: str): + async def delete_message(self, receipt_handle: str): # The receipt handle here must match the message ID # self.client.xack(self.queue, self.group, receipt_handle) - keys = self.queue, - argv = self.group, receipt_handle, - self.client.eval(ACK_FUNCTION, len(keys), *keys, *argv) + keys = (self.queue,) + argv = ( + self.group, + receipt_handle, + ) + await self.client.eval(ACK_FUNCTION, len(keys), *keys, *argv) - def set_message_timeout(self, receipt_handle: str, seconds: int): + async def set_message_timeout(self, receipt_handle: str, seconds: int): # The seconds don't do anything for now; I wasn't sure how to translate a fixed timeout to the Redis model # At least this resets the idle time @jkz - self.client.xclaim(self.queue, self.group, self.consumer, min_idle_time=0, message_ids=[receipt_handle]) + await self.client.xclaim( + self.queue, + self.group, + self.consumer, + min_idle_time=0, + message_ids=[receipt_handle], + ) - def get_info(self): + async def get_info(self): + total = await self.client.xlen(self.queue) + pending = await self.client.xpending(self.queue, self.group) return QueueInfo( - num_messages=self.client.xlen(self.queue), - num_inflight_messages=self.client.xpending(self.queue, self.group)["pending"], + num_messages=total, + num_inflight_messages=pending["pending"], ) - -class RedisMemStore(Store): - client: redis.Redis - - def __init__(self, client: redis.Redis): - self.client = client - - def key(self, key: MemKey) -> str: - return f"{key.type}:{key.id}" - - def __getitem__(self, key: MemKey) -> bytes: - value = self.client.get(self.key(key)) - if value is None: - raise KeyError(key) - return value - - def __setitem__(self, key: str, value: bytes): - self.client.set(self.key(key), value) - - def __delitem__(self, key: str): - self.client.delete(self.key(key)) - - def __contains__(self, key: str) -> bool: - return self.client.exists(self.key(key)) == 1 - - def compare_and_set(self, key: str, value: bytes, expected: bytes): - keys = self.key(key), - argv = value, expected, - if not self.client.eval(COMPARE_AND_SET_SCRIPT, len(keys), *keys, *argv): - raise CompareMismatch - - def compare_and_delete(self, key: str, expected: bytes): - keys = self.key(key), - argv = expected, - if not self.client.eval(COMPARE_AND_DELETE_SCRIPT, len(keys), *keys, *argv): - raise CompareMismatch diff --git a/src/brrr/backends/sqs.py b/src/brrr/backends/sqs.py deleted file mode 100644 index 2bb6806..0000000 --- a/src/brrr/backends/sqs.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import typing - -from ..queue import Queue, Message, QueueInfo, QueueIsEmpty - -if typing.TYPE_CHECKING: - from mypy_boto3_sqs import SQSClient - -class SqsQueue(Queue): - def __init__(self, client: SQSClient, url: str): - self.client = client - self.url = url - - def put(self, message: str): - self.client.send_message( - QueueUrl=self.url, - MessageBody=message - ) - - def get_message(self) -> Message: - response = self.client.receive_message( - QueueUrl=self.url, - MaxNumberOfMessages=1, - WaitTimeSeconds=self.recv_block_secs, - ) - - if "Messages" not in response: - raise QueueIsEmpty - - return Message(response["Messages"][0]["Body"], response["Messages"][0]["ReceiptHandle"]) - - def delete_message(self, receipt_handle: str): - self.client.delete_message( - QueueUrl=self.url, - ReceiptHandle=receipt_handle - ) - - def set_message_timeout(self, receipt_handle, seconds): - self.client.change_message_visibility( - QueueUrl=self.url, - ReceiptHandle=receipt_handle, - VisibilityTimeout=seconds - ) - - def get_info(self): - response = self.client.get_queue_attributes( - QueueUrl=self.url, - AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"] - ) - return QueueInfo( - num_messages=int(response["Attributes"]["ApproximateNumberOfMessages"]), - num_inflight_messages=int(response["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) - ) diff --git a/src/brrr/brrr.py b/src/brrr/brrr.py index 3fb985e..c04b70d 100644 --- a/src/brrr/brrr.py +++ b/src/brrr/brrr.py @@ -1,45 +1,48 @@ -from typing import Any, Callable, Union - -import logging import asyncio -import threading +from collections.abc import Awaitable, Callable, Sequence +import logging +from typing import Any, Union from .store import AlreadyExists, Call, CompareMismatch, Memory, Store, input_hash from .queue import Queue, QueueIsClosed, QueueIsEmpty logger = logging.getLogger(__name__) +# I’d like for the typechecker to raise an error when a value with this type is +# called as a function without await. How? +AsyncFunc = Callable[..., Awaitable[Any]] + + class Defer(Exception): """ When a task is called and hasn't been computed yet, a Defer exception is raised Workers catch this exception and schedule the task to be computed """ + calls: list[Call] + def __init__(self, calls: list[Call]): self.calls = calls + class Brrr: """ All state for brrr to function wrapped in a container. """ + def requires_setup(method): def wrapper(self, *args, **kwargs): if self.queue is None or self.memory is None: raise Exception("Brrr not set up") return method(self, *args, **kwargs) + return wrapper # The worker loop (as of writing) is synchronous so it can safely set a # local global variable to indicate that it is a worker thread, which, for # tasks, means that their Defer raises will be caught and handled by the # worker - worker_singleton: Union['Wrrrker', None] - - # For threaded workers, each worker registers itself in this dict by thread id - worker_threads: dict[int, 'Wrrrker'] - - # For async workers, - worker_loops: dict[int, 'Wrrrker'] + worker_singleton: Union["Wrrrker", None] # A storage backend for calls, values and pending returns memory: Memory | None @@ -47,24 +50,13 @@ def wrapper(self, *args, **kwargs): queue: Queue | None # Dictionary of task_name to task instance - tasks = dict[str, 'Task'] + tasks = dict[str, "Task"] def __init__(self): - self.worker_singleton = None - self.worker_threads = {} - self.worker_loops = {} self.tasks = {} self.queue = None self.memory = None - - # TODO do we like the idea of brrr as a context manager? - def __enter__(self): - pass - def __exit__(self, exc_type, exc_value, traceback): - if hasattr(self.queue, "__exit__"): - self.queue.__exit__(exc_type, exc_value, traceback) - if hasattr(self.memory, "__exit__"): - self.memory.__exit__(exc_type, exc_value, traceback) + self.worker_singleton = None # TODO Do we want to pass in a memstore/kv instead? def setup(self, queue: Queue, store: Store): @@ -72,38 +64,25 @@ def setup(self, queue: Queue, store: Store): self.queue = queue self.memory = Memory(store) - def are_we_inside_worker_context(self): - if self.worker_singleton: - return True - elif self.worker_threads: - # For synchronous workers, we can use a thread-local global variable - return threading.current_thread() in self.worker_threads - elif self.worker_loops: - try: - # For async workers, we can check the asyncio loop - return asyncio.get_running_loop() in self.worker_loops - except RuntimeError: - return False - else: - return False - + def are_we_inside_worker_context(self) -> Any: + return self.worker_singleton @requires_setup - def gather(self, *task_lambdas) -> list[Any]: + async def gather(self, *task_lambdas) -> Sequence[Any]: """ Takes a number of task lambdas and calls each of them. If they've all been computed, return their values, Otherwise raise jobs for those that haven't been computed """ if not self.are_we_inside_worker_context(): - return [task_lambda() for task_lambda in task_lambdas] + return await asyncio.gather(*(f() for f in task_lambdas)) defers = [] values = [] for task_lambda in task_lambdas: try: - values.append(task_lambda()) + values.append(await task_lambda()) except Defer as d: defers.extend(d.calls) @@ -112,7 +91,7 @@ def gather(self, *task_lambdas) -> list[Any]: return values - def schedule(self, task_name: str, args: tuple, kwargs: dict): + async def schedule(self, task_name: str, args: tuple, kwargs: dict): """Public-facing one-shot schedule method. The exact API for the type of args and kwargs is still WIP. We're doing @@ -121,10 +100,10 @@ def schedule(self, task_name: str, args: tuple, kwargs: dict): Don't use this internally. """ - return self._schedule_call(Call(task_name, (args, kwargs))) + return await self._schedule_call(Call(task_name, (args, kwargs))) @requires_setup - def _schedule_call(self, call: Call, parent_key=None): + async def _schedule_call(self, call: Call, parent_key=None): """Schedule this call on the brrr workforce. This is the real internal entrypoint which should be used by all brrr @@ -133,63 +112,50 @@ def _schedule_call(self, call: Call, parent_key=None): """ # Value has been computed already, return straight to the parent (if there is one) - if self.memory.has_value(call.memo_key): + if await self.memory.has_value(call.memo_key): if parent_key is not None: - self.queue.put(parent_key) + await self.queue.put(parent_key) return # If this call has previously been scheduled, don't reschedule it - if not self.memory.has_call(call): - self.memory.set_call(call) - self.queue.put(call.memo_key) + if not await self.memory.has_call(call): + await self.memory.set_call(call) + await self.queue.put(call.memo_key) if parent_key is not None: - self.memory.add_pending_returns(call.memo_key, set([parent_key])) + await self.memory.add_pending_returns(call.memo_key, set([parent_key])) @requires_setup - def read(self, task_name: str, args: tuple, kwargs: dict): + async def read(self, task_name: str, args: tuple, kwargs: dict): """ Returns the value of a task, or raises a KeyError if it's not present in the store """ memo_key = Call(task_name, (args, kwargs)).memo_key - return self.memory.get_value(memo_key) - + return await self.memory.get_value(memo_key) @requires_setup - def evaluate(self, call: Call) -> Any: + async def evaluate(self, call: Call) -> Any: """ Evaluate a frame, which means calling the tasks function with its arguments """ task = self.tasks[call.task_name] - return task.evaluate(call.argv) + return await task.evaluate(call.argv) - def register_task(self, fn: Callable, name: str = None) -> 'Task': + def register_task(self, fn: AsyncFunc, name: str = None) -> "Task": task = Task(self, fn, name) if task.name in self.tasks: raise Exception(f"Task {task.name} already exists") self.tasks[task.name] = task return task - def task(self, fn: Callable, name: str = None) -> 'Task': + def task(self, fn: AsyncFunc, name: str = None) -> "Task": return Task(self, fn, name) - async def wrrrk_async(self, workers: int = 1): - """ - Start a number of async worker loops - """ - await asyncio.gather( - *(Wrrrker(self).loop_async() for _ in range(workers)) - ) - - def wrrrk(self, threads: int = 1): + async def wrrrk(self): """ - Spin up a number of worker threads + Spin up a single brrr worker. """ - if threads == 1: - Wrrrker(self).loop() - else: - for _ in range(threads): - threading.Thread(target=Wrrrker(self).loop).start() + await Wrrrker(self).loop() class Task: @@ -201,24 +167,24 @@ class Task: A task can not write to the store, only read from it """ - fn: Any + fn: AsyncFunc name: str brrr: Brrr - def __init__(self, brrr: Brrr, fn, name: str = None): + def __init__(self, brrr: Brrr, fn: AsyncFunc, name: str = None): self.brrr = brrr self.fn = fn self.name = name or fn.__name__ # Calling a function returns the value if it has already been computed. # Otherwise, it raises a Call exception to schedule the computation - def __call__(self, *args, **kwargs): + async def __call__(self, *args, **kwargs): argv = (args, kwargs) if not self.brrr.are_we_inside_worker_context(): - return self.evaluate(argv) + return await self.evaluate(argv) memo_key = input_hash(self.name, argv) try: - return self.brrr.memory.get_value(memo_key) + return await self.brrr.memory.get_value(memo_key) except KeyError: raise Defer([Call(self.name, argv)]) @@ -228,7 +194,7 @@ def to_lambda(self, *args, **kwargs): """ return lambda: self(*args, **kwargs) - def map(self, args: list[Union[dict, list, tuple[tuple, dict]]]): + async def map(self, args: list[Union[dict, list, tuple[tuple, dict]]]): """ Fanning out, a map function returns the values if they have already been computed. Otherwise, it raises a list of Call exceptions to schedule the computation, @@ -238,20 +204,31 @@ def map(self, args: list[Union[dict, list, tuple[tuple, dict]]]): #TODO we _could_ support a list of elements to get passed as a single arg each """ argvs = [ - (arg, {}) if isinstance(arg, list) else ((), arg) if isinstance(arg, dict) else arg + (arg, {}) + if isinstance(arg, list) + else ((), arg) + if isinstance(arg, dict) + else arg for arg in args ] - return self.brrr.gather(*(self.to_lambda(*argv[0], **argv[1]) for argv in argvs)) + return await self.brrr.gather( + *(self.to_lambda(*argv[0], **argv[1]) for argv in argvs) + ) - def evaluate(self, argv): - return self.fn(*argv[0], **argv[1]) + # I think /technically/ the async + await here cancel each other out and you + # could do without either, but there are so many gotchas around it and + # possible points of failure that it’s nice to at least ensure this _is_ a + # coroutine. + async def evaluate(self, argv): + return await self.fn(*argv[0], **argv[1]) - def schedule(self, *args, **kwargs): + async def schedule(self, *args, **kwargs): """ This puts the task call on the queue, but doesn't return the result! """ call = Call(self.name, (args, kwargs)) - return self.brrr._schedule_call(call) + return await self.brrr._schedule_call(call) + class Wrrrker: def __init__(self, brrr: Brrr): @@ -267,28 +244,35 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): self.brrr.worker_singleton = None - def resolve_call(self, memo_key: str): + async def resolve_call(self, memo_key: str): """ A queue message is a frame key and a receipt handle The frame key is used to look up the job to be done, the receipt handle is used to tell the queue that the job is done """ - call = self.brrr.memory.get_call(memo_key) + call = await self.brrr.memory.get_call(memo_key) logger.info("Resolving %s %s %s", memo_key, call.task_name, call.argv) try: - value = self.brrr.evaluate(call) + value = await self.brrr.evaluate(call) except Defer as defer: + logger.debug( + "Deferring %s %s %s: %d missing calls", + memo_key, + call.task_name, + call.argv, + len(defer.calls), + ) for call in defer.calls: - self.brrr._schedule_call(call, memo_key) + await self.brrr._schedule_call(call, memo_key) return # We can end up in a race against another worker to write the value. # We only accept the first entry and the rest will be bounced try: - self.brrr.memory.set_value(call.memo_key, value) + await self.brrr.memory.set_value(call.memo_key, value) except AlreadyExists: # It is possible that we can formally prove that this situation means we don't need to # requeue here. Until then, let's just feel safer and run through any pending parents below. @@ -306,22 +290,21 @@ def resolve_call(self, memo_key: str): handled_returns = set() try: - all_returns = self.brrr.memory.get_pending_returns(call.memo_key) + all_returns = await self.brrr.memory.get_pending_returns(call.memo_key) except KeyError: return for memo_key in all_returns - handled_returns: - self.brrr.queue.put(memo_key) + await self.brrr.queue.put(memo_key) try: - self.brrr.memory.delete_pending_returns(call.memo_key, all_returns) + await self.brrr.memory.delete_pending_returns(call.memo_key, all_returns) except CompareMismatch: # TODO tried to loop here but the dynamo CAS wasn't working. Perhaps revisit at some point # Not required though as the root level task will eventually clean this up pass - # TODO exit when queue empty? - def loop(self): + async def loop(self): """ Workers take jobs from the queue, one at a time, and handle them. They have read and write access to the store, and are responsible for @@ -332,49 +315,15 @@ def loop(self): while True: try: # This is presumed to be a long poll - message = self.brrr.queue.get_message() - except QueueIsEmpty: - logger.info("Queue is empty") - continue - except QueueIsClosed: - logger.info("Queue is closed") - return - - memo_key = message.body - self.resolve_call(memo_key) - - self.brrr.queue.delete_message(message.receipt_handle) - - async def loop_async(self): - with self: - logger.info("Worker Started") - while True: - try: - message = await self.brrr.queue.get_message_async() + message = await self.brrr.queue.get_message() except QueueIsEmpty: - logger.info("Queue is empty") + logger.debug("Queue is empty") continue except QueueIsClosed: logger.info("Queue is closed") return - memo_key = message.body - self.resolve_call(memo_key) - - self.brrr.queue.delete_message(message.receipt_handle) - + await self.resolve_call(memo_key) -class ThreadWrrrker(Wrrrker): - # The context manager maintains a thread-local global variable to indicate that the thread is a worker - # and that any invoked tasks can raise Defer exceptions - def __enter__(self): - tid = threading.current_thread() - if tid in self.brrr.worker_threads: - raise Exception("Worker already running in this thread") - - self.brrr.worker_threads[tid] = self - return self - - def __exit__(self, exc_type, exc_value, traceback): - del self.brrr.worker_threads[threading.current_thread()] + await self.brrr.queue.delete_message(message.receipt_handle) diff --git a/src/brrr/compat.py b/src/brrr/compat.py deleted file mode 100644 index 9ec8c8d..0000000 --- a/src/brrr/compat.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Callable - -from .brrr import Task - -class CompatTask(Task): - def to_deployment(self): - return self - -def serve(*tasks): - pass - -def deploy(*tasks): - pass - -def task( - fn=None, - *, - name: str = None, - description: str = None, - timeout_seconds: int = None, - cache_key_fn: Callable[[tuple[tuple, dict]], str] = None, - cache_expiration: int = None, - retries: int = None, - retry_delay_seconds: int = None, - log_prints: bool, - **kwargs -): - def decorator(_fn): - return CompatTask( - _fn, - name=name or _fn.__name__, - ) - return decorator if fn is None else decorator(fn) - - -def flow( - fn=None, - *, - name: str = None, - description: str = None, - timeout_seconds: int = None, - retries: int = None, - retry_delay_seconds: int = None, - flow_run_name: str = None, - validate_parameters: bool = None, - version: str = None, -): - return task(fn, name=name, description=description, timeout_seconds=timeout_seconds, retries=retries, retry_delay_seconds=retry_delay_seconds) diff --git a/src/brrr/queue.py b/src/brrr/queue.py index d62dcef..ee2c20e 100644 --- a/src/brrr/queue.py +++ b/src/brrr/queue.py @@ -5,26 +5,32 @@ class QueueIsEmpty(Exception): pass + class QueueIsClosed(Exception): """ Queue implementations may raise this exception for workers to shut down gracefully """ + @dataclass class Message: body: str receipt_handle: str + @dataclass class QueueInfo: """ Approximate info about the queue """ + num_messages: int num_inflight_messages: int + # Infra abstractions + class Queue(ABC): # Inspired by SQS: maximum time for a get_message call to block while # waiting for new messages, if there are currently no messages on the queue. @@ -40,17 +46,15 @@ class Queue(ABC): deletes_messages: bool @abstractmethod - def put(self, body: str): ... - @abstractmethod - async def get_message_async(self) -> Message: ... + async def put(self, body: str): ... @abstractmethod - def get_message(self) -> Message: ... + async def get_message(self) -> Message: ... @abstractmethod - def delete_message(self, receipt_handle: str): ... + async def delete_message(self, receipt_handle: str): ... @abstractmethod - def set_message_timeout(self, receipt_handle: str, seconds: int): ... + async def set_message_timeout(self, receipt_handle: str, seconds: int): ... @abstractmethod - def get_info(self) -> QueueInfo: ... + async def get_info(self) -> QueueInfo: ... class RichQueue(Queue): diff --git a/src/brrr/store.py b/src/brrr/store.py index 976b69f..11a9e57 100644 --- a/src/brrr/store.py +++ b/src/brrr/store.py @@ -1,12 +1,13 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, TypeVar from collections import namedtuple +from typing import Any, TypeVar import pickle from hashlib import sha256 + def input_hash(*args): return sha256(":".join(map(str, args)).encode()).hexdigest() @@ -15,6 +16,7 @@ def input_hash(*args): # A memoization cache for tasks that have already been computed, based on their task name and input arguments + # Using the same memo key, we store the task and its argv here so we can retrieve them in workers @dataclass class Call: @@ -35,6 +37,7 @@ class Info: Optional information about a task. Does not affect the computation, but may instruct orchestration """ + description: str | None timeout_seconds: int | None retries: int | None @@ -44,11 +47,17 @@ class Info: MemKey = namedtuple("MemKey", ["type", "id"]) + class CompareMismatch(Exception): ... + + class AlreadyExists(Exception): ... + T = TypeVar("T") + +# TODO: Use custom error rather than KeyError as part of contract. class Store[T](ABC): """ A key-value store with a dict-like interface. @@ -57,23 +66,25 @@ class Store[T](ABC): All mutate operations MUST be idempotent All getters MUST throw a KeyError for missing keys """ + @abstractmethod - def __contains__(self, key: MemKey) -> bool: ... + async def has(self, key: MemKey) -> bool: ... @abstractmethod - def __getitem__(self, key: MemKey) -> T: ... + async def get(self, key: MemKey) -> T: ... @abstractmethod - def __setitem__(self, key: MemKey, value: T): ... + async def set(self, key: MemKey, value: T): ... @abstractmethod - def __delitem__(self, key: MemKey): ... + async def delete(self, key: MemKey): ... @abstractmethod - def compare_and_set(self, key: MemKey, value: T, expected: T | None): + async def compare_and_set(self, key: MemKey, value: T, expected: T | None): """ Only set the value, as a transaction, if the existing value matches the expected value Or, if expected value is None, if the key does not exist """ ... + @abstractmethod - def compare_and_delete(self, key: MemKey, expected: T): + async def compare_and_delete(self, key: MemKey, expected: T): """ Only delete the value, as a transaction, if the existing value matches the expected value This is a noop if the expected value is None. While we could allow it, we've chosen not to, @@ -82,101 +93,111 @@ def compare_and_delete(self, key: MemKey, expected: T): """ ... + class PickleJar(Store[Any]): """ A dict-like object that pickles on set and unpickles on get """ + pickles: Store[bytes] def __init__(self, store: Store): self.pickles = store - def __contains__(self, key: MemKey): - return key in self.pickles + async def has(self, key: MemKey): + return await self.pickles.has(key) - def __getitem__(self, key: MemKey): - return pickle.loads(self.pickles[key]) + async def get(self, key: MemKey): + return pickle.loads(await self.pickles.get(key)) - def __setitem__(self, key: MemKey, value): - self.pickles[key] = pickle.dumps(value) + async def set(self, key: MemKey, value): + await self.pickles.set(key, pickle.dumps(value)) - def __delitem__(self, key: MemKey, value): - del self.pickles[key] + async def delete(self, key: MemKey): + await self.pickles.delete(key) # BEWARE `None` has special semantics here. None means "expect key to be missing" # which means we never pickle the value `None`. TBD whether we want to support # these semantics, or instead use a "Optional" value wrapper # Throw CompareMismatch if the expected value does not match the actual value - def compare_and_set(self, key: MemKey, value: Any, expected: Any | None): + async def compare_and_set(self, key: MemKey, value: Any, expected: Any | None): assert value is not None, "Value cannot be None" - self.pickles.compare_and_set(key, pickle.dumps(value), None if expected is None else pickle.dumps(expected)) + await self.pickles.compare_and_set( + key, + pickle.dumps(value), + None if expected is None else pickle.dumps(expected), + ) # Throw CompareMismatch if the expected value does not match the actual value - def compare_and_delete(self, key: MemKey, expected: Any): - self.pickles.compare_and_delete(key, None if expected is None else pickle.dumps(expected)) + async def compare_and_delete(self, key: MemKey, expected: Any): + await self.pickles.compare_and_delete( + key, None if expected is None else pickle.dumps(expected) + ) + class Memory: """ A memstore that uses a PickleJar as its backend """ + def __init__(self, store: Store): self.pickles = PickleJar(store) - def get_call(self, memo_key: str) -> Call: - val = self.pickles[MemKey("call", memo_key)] + async def get_call(self, memo_key: str) -> Call: + val = await self.pickles.get(MemKey("call", memo_key)) assert isinstance(val, Call) return val - def has_call(self, call: Call): - return MemKey("call", call.memo_key) in self.pickles + async def has_call(self, call: Call): + return await self.pickles.has(MemKey("call", call.memo_key)) - def set_call(self, call: Call): + async def set_call(self, call: Call): if not isinstance(call, Call): raise ValueError(f"set_call expected a Call, got {call}") - self.pickles[MemKey("call", call.memo_key)] = call + await self.pickles.set(MemKey("call", call.memo_key), call) - def has_value(self, memo_key: str) -> bool: - return MemKey("value", memo_key) in self.pickles + async def has_value(self, memo_key: str) -> bool: + return await self.pickles.has(MemKey("value", memo_key)) - def get_value(self, memo_key: str) -> Any: - return self.pickles[MemKey("value", memo_key)] + async def get_value(self, memo_key: str) -> Any: + return await self.pickles.get(MemKey("value", memo_key)) - def set_value(self, memo_key: str, value: Any): + async def set_value(self, memo_key: str, value: Any): if value is None: raise ValueError("set_value value cannot be None") # Only set if the value is not already set try: - self.pickles.compare_and_set(MemKey("value", memo_key), value, None) + await self.pickles.compare_and_set(MemKey("value", memo_key), value, None) except CompareMismatch: # Throwing over passing here; Because of idempotency, we only ever want # one value to be set for a given memo_key. If we silently ignored this here, # we could end up executing code with the wrong value raise AlreadyExists(f"set_value: value already set for {memo_key}") - def get_info(self, task_name: str) -> Info: - val = self.pickles[MemKey("info", task_name)] + async def get_info(self, task_name: str) -> Info: + val = await self.pickles.get(MemKey("info", task_name)) assert isinstance(val, Info) return val - def set_info(self, task_name: str, value: Info): - self.pickles[MemKey("info", task_name)] = value + async def set_info(self, task_name: str, value: Info): + await self.pickles.set(MemKey("info", task_name), value) - def get_pending_returns(self, memo_key: str) -> set[str]: - val = self.pickles[MemKey("pending_returns", memo_key)] + async def get_pending_returns(self, memo_key: str) -> set[str]: + val = await self.pickles.get(MemKey("pending_returns", memo_key)) val = set(val.split(",")) assert isinstance(val, set) and all(isinstance(x, str) for x in val) return val - def add_pending_returns(self, memo_key: str, updated_keys: set[str]): + async def add_pending_returns(self, memo_key: str, updated_keys: set[str]): if any(not isinstance(k, str) for k in updated_keys): raise ValueError("add_pending_returns: all keys must be strings") # TODO is there a number of retries we should throw for? while True: try: - existing_keys = self.get_pending_returns(memo_key) + existing_keys = await self.get_pending_returns(memo_key) except KeyError: existing_keys = None else: @@ -187,18 +208,23 @@ def add_pending_returns(self, memo_key: str, updated_keys: set[str]): # could use lists and keep them sorted and is a safe compare across implementations. # This hack gets us to v1 keys_to_set = ",".join(sorted(updated_keys)) - keys_to_match = None if existing_keys is None else ",".join(sorted(existing_keys)) - self.pickles.compare_and_set(MemKey("pending_returns", memo_key), keys_to_set, keys_to_match) + keys_to_match = ( + None if existing_keys is None else ",".join(sorted(existing_keys)) + ) + await self.pickles.compare_and_set( + MemKey("pending_returns", memo_key), keys_to_set, keys_to_match + ) except CompareMismatch: continue else: return - def set_pending_returns(self, memo_key: str, updated_keys: set[str], existing_keys: set[str] | None): - updated_keys = ",".join(sorted(updated_keys)) - existing_keys = ",".join(sorted(existing_keys)) - self.pickles.compare_and_set(MemKey("pending_returns", memo_key), updated_keys, existing_keys) - - def delete_pending_returns(self, memo_key: str, existing_keys: set[str] | None): - existing_keys = None if existing_keys is None else ",".join(sorted(existing_keys)) - self.pickles.compare_and_delete(MemKey("pending_returns", memo_key), existing_keys) + async def delete_pending_returns( + self, memo_key: str, existing_keys: set[str] | None + ): + existing_keys = ( + None if existing_keys is None else ",".join(sorted(existing_keys)) + ) + await self.pickles.compare_and_delete( + MemKey("pending_returns", memo_key), existing_keys + ) diff --git a/tests/test_queue.py b/tests/test_queue.py index 649a994..18488fc 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,73 +1,70 @@ +from abc import ABC, abstractmethod +from contextlib import asynccontextmanager +from typing import AsyncIterator + import pytest -from abc import ABC, abstractmethod -from brrr.queue import Queue, QueueIsEmpty, QueueIsClosed +from brrr.queue import Queue, QueueIsEmpty from brrr.backends.in_memory import InMemoryQueue + class QueueContract(ABC): throws_closes: bool has_accurate_info: bool deletes_messages: bool @abstractmethod - def get_queue(self) -> Queue: + @asynccontextmanager + async def with_queue(self) -> AsyncIterator[Queue]: """ - This should return a fresh, empty queue instance + A context manager which calls test function f with a queue """ ... - # TODO Move this to a mixin? This is pretty ugly. - # One problem is that we have no control over clients, which usually do the closing - @abstractmethod - def close_queue(self): - ... - - def test_queue_raises_empty(self): - queue = self.get_queue() - with pytest.raises(QueueIsEmpty): - queue.get_message() - - def test_queue_enqueues(self): - queue = self.get_queue() - messages = set(["message-1", "message-2", "message-3"]) + async def test_queue_raises_empty(self): + async with self.with_queue() as queue: + with pytest.raises(QueueIsEmpty): + await queue.get_message() - if self.has_accurate_info: assert queue.get_info().num_messages == 0 + async def test_queue_enqueues(self): + async with self.with_queue() as queue: + messages = set(["message-1", "message-2", "message-3"]) - queue.put("message-1") - if self.has_accurate_info: assert queue.get_info().num_messages == 1 + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 0 - queue.put("message-2") - if self.has_accurate_info: assert queue.get_info().num_messages == 2 + await queue.put("message-1") + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 1 - queue.put("message-3") - if self.has_accurate_info: assert queue.get_info().num_messages == 3 + await queue.put("message-2") + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 2 - message = queue.get_message() - assert message.body in messages - messages.remove(message.body) - if self.has_accurate_info: assert queue.get_info().num_messages == 2 + await queue.put("message-3") + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 3 - message = queue.get_message() - assert message.body in messages - messages.remove(message.body) - if self.has_accurate_info: assert queue.get_info().num_messages == 1 + message = await queue.get_message() + assert message.body in messages + messages.remove(message.body) + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 2 - message = queue.get_message() - assert message.body in messages - messages.remove(message.body) - if self.has_accurate_info: assert queue.get_info().num_messages == 0 + message = await queue.get_message() + assert message.body in messages + messages.remove(message.body) + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 1 - with pytest.raises(QueueIsEmpty): - queue.get_message() + message = await queue.get_message() + assert message.body in messages + messages.remove(message.body) + if self.has_accurate_info: + assert (await queue.get_info()).num_messages == 0 - def test_closing(self): - if not self.throws_closes: - pytest.skip("Queue does not throw QueueIsClosed exceptions") - queue = self.get_queue() - queue.put("message-1") - self.close_queue(queue) - with pytest.raises(QueueIsClosed): - queue.get_message() + with pytest.raises(QueueIsEmpty): + await queue.get_message() class TestInMemoryQueue(QueueContract): @@ -75,9 +72,6 @@ class TestInMemoryQueue(QueueContract): has_accurate_info = True deletes_messages = True - def get_queue(self) -> Queue: - return InMemoryQueue() - - def close_queue(self, queue): - queue.closed = True - + @asynccontextmanager + async def with_queue(self) -> AsyncIterator[Queue]: + yield InMemoryQueue() diff --git a/tests/test_store.py b/tests/test_store.py index a2eabc6..65d9120 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,4 +1,4 @@ -from brrr.backends.in_memory import InMemoryByteStore, InMemoryQueue +from brrr.backends.in_memory import InMemoryByteStore import pytest from abc import ABC, abstractmethod @@ -13,172 +13,174 @@ def get_store(self) -> Store[bytes]: """ ... - def test_contains(self): + async def test_has(self): store = self.get_store() a1 = MemKey("type-a", "id-1") a2 = MemKey("type-a", "id-2") b1 = MemKey("type-b", "id-1") - assert a1 not in store - assert a2 not in store - assert b1 not in store - - store[a1] = b"value-1" - assert a1 in store - assert a2 not in store - assert b1 not in store - - store[a2] = b"value-2" - assert a1 in store - assert a2 in store - assert b1 not in store - - store[b1] = b"value-3" - assert a1 in store - assert a2 in store - assert b1 in store - - del store[a1] - assert a1 not in store - assert a2 in store - assert b1 in store - - del store[a2] - assert a1 not in store - assert a2 not in store - assert b1 in store - - del store[b1] - assert a1 not in store - assert a2 not in store - assert b1 not in store - - def test_get_set(self): + assert not await store.has(a1) + assert not await store.has(a2) + assert not await store.has(b1) + + await store.set(a1, b"value-1") + assert await store.has(a1) + assert not await store.has(a2) + assert not await store.has(b1) + + await store.set(a2, b"value-2") + assert await store.has(a1) + assert await store.has(a2) + assert not await store.has(b1) + + await store.set(b1, b"value-3") + assert await store.has(a1) + assert await store.has(a2) + assert await store.has(b1) + + await store.delete(a1) + assert not await store.has(a1) + assert await store.has(a2) + assert await store.has(b1) + + await store.delete(a2) + assert not await store.has(a1) + assert not await store.has(a2) + assert await store.has(b1) + + await store.delete(b1) + assert not await store.has(a1) + assert not await store.has(a2) + assert not await store.has(b1) + + async def test_get_set(self): store = self.get_store() a1 = MemKey("type-a", "id-1") a2 = MemKey("type-a", "id-2") b1 = MemKey("type-b", "id-1") - store[a1] = b"value-1" - store[a2] = b"value-2" - store[b1] = b"value-3" + await store.set(a1, b"value-1") + await store.set(a2, b"value-2") + await store.set(b1, b"value-3") - assert store[a1] == b"value-1" - assert store[a2] == b"value-2" - assert store[b1] == b"value-3" + assert await store.get(a1) == b"value-1" + assert await store.get(a2) == b"value-2" + assert await store.get(b1) == b"value-3" - store[a1] = b"value-4" - assert store[a1] == b"value-4" + await store.set(a1, b"value-4") + assert await store.get(a1) == b"value-4" - def test_key_error(self): + async def test_key_error(self): store = self.get_store() a1 = MemKey("type-a", "id-1") with pytest.raises(KeyError): - store[a1] + await store.get(a1) - del store[a1] + await store.delete(a1) with pytest.raises(KeyError): - store[a1] + await store.get(a1) - store[a1] = b"value-1" + await store.set(a1, b"value-1") - assert store[a1] == b"value-1" + assert await store.get(a1) == b"value-1" - del store[a1] + await store.delete(a1) with pytest.raises(KeyError): - store[a1] + await store.get(a1) - def test_compare_and_set(self): + async def test_compare_and_set(self): store = self.get_store() a1 = MemKey("type-a", "id-1") - store[a1] = b"value-1" + await store.set(a1, b"value-1") with pytest.raises(CompareMismatch): - store.compare_and_set(a1, b"value-2", b"value-3") + await store.compare_and_set(a1, b"value-2", b"value-3") - store.compare_and_set(a1, b"value-2", b"value-1") + await store.compare_and_set(a1, b"value-2", b"value-1") - assert store[a1] == b"value-2" + assert await store.get(a1) == b"value-2" - def test_compare_and_delete(self): + async def test_compare_and_delete(self): store = self.get_store() a1 = MemKey("type-a", "id-1") - store[a1] = b"value-1" + await store.set(a1, b"value-1") with pytest.raises(CompareMismatch): - store.compare_and_delete(a1, b"value-2") + await store.compare_and_delete(a1, b"value-2") - assert store[a1] == b"value-1" + assert await store.get(a1) == b"value-1" - store.compare_and_delete(a1, b"value-1") + await store.compare_and_delete(a1, b"value-1") with pytest.raises(KeyError): - store[a1] + await store.get(a1) + class TestMemory: def get_memory(self) -> Memory: store = InMemoryByteStore() return Memory(store) - def test_call(self): + async def test_call(self): memory = self.get_memory() with pytest.raises(ValueError): - memory.set_call("foo") + await memory.set_call("foo") with pytest.raises(KeyError): - memory.get_call("non-existent") + await memory.get_call("non-existent") call = Call("task", (("arg-1", "arg-2"), {"a": 1, "b": 2})) - assert not memory.has_call(call) + assert not await memory.has_call(call) - memory.set_call(call) - assert memory.has_call(call) - assert memory.get_call(call.memo_key) == call + await memory.set_call(call) + assert await memory.has_call(call) + assert await memory.get_call(call.memo_key) == call - def test_value(self): + async def test_value(self): memory = self.get_memory() - assert not memory.has_value("key") + assert not await memory.has_value("key") - memory.set_value("key", {"test": 1}) - assert memory.has_value("key") - assert memory.get_value("key") == {"test": 1} + await memory.set_value("key", {"test": 1}) + assert await memory.has_value("key") + assert await memory.get_value("key") == {"test": 1} with pytest.raises(AlreadyExists): - memory.set_value("key", {"test": 2}) + await memory.set_value("key", {"test": 2}) - def test_pending_returns(self): + async def test_pending_returns(self): memory = self.get_memory() with pytest.raises(KeyError): - assert not memory.get_pending_returns("key") + assert not await memory.get_pending_returns("key") with pytest.raises(ValueError): - memory.add_pending_returns("key", {123}) + await memory.add_pending_returns("key", {123}) - memory.add_pending_returns("key", {"a", "b"}) + await memory.add_pending_returns("key", {"a", "b"}) - assert memory.get_pending_returns("key") == {"a", "b"} + assert await memory.get_pending_returns("key") == {"a", "b"} - memory.add_pending_returns("key", {"c", "d"}) - assert memory.get_pending_returns("key") == {"a", "b", "c", "d"} + await memory.add_pending_returns("key", {"c", "d"}) + assert await memory.get_pending_returns("key") == {"a", "b", "c", "d"} with pytest.raises(CompareMismatch): - memory.delete_pending_returns("key", {"a", "b", "c", "d", "wrong"}) - assert memory.get_pending_returns("key") == {"a", "b", "c", "d"} + await memory.delete_pending_returns("key", {"a", "b", "c", "d", "wrong"}) + assert await memory.get_pending_returns("key") == {"a", "b", "c", "d"} - memory.delete_pending_returns("key", {"a", "b", "c", "d"}) + await memory.delete_pending_returns("key", {"a", "b", "c", "d"}) with pytest.raises(KeyError): - assert not memory.get_pending_returns("key") + assert not await memory.get_pending_returns("key") + class TestInMemoryByteStore(ByteStoreContract): def get_store(self) -> Store[bytes]: diff --git a/uv.lock b/uv.lock index bb69353..f1b7aad 100644 --- a/uv.lock +++ b/uv.lock @@ -2,105 +2,202 @@ version = 1 requires-python = ">=3.12, <4" [[package]] -name = "boto3" -version = "1.35.71" +name = "aioboto3" +version = "13.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/6d/8e89a60e756c5da4ef56afa738aabed4aa16945676e98b23ede17dffb007/boto3-1.35.71.tar.gz", hash = "sha256:3ed7172b3d4fceb6218bb0ec3668c4d40c03690939c2fca4f22bb875d741a07f", size = 111006 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/29/4684abe9c9f60620576292fe2fcc26da618e20185f0ec3c2cb8d941e5aa6/aioboto3-13.3.0.tar.gz", hash = "sha256:74c2ee3018dcf5714b92bbbe4ce6b78b6dde1e1804de42c784555e40634f8872", size = 32511 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/db/c544d414b6c903011489fc33e2c171d497437b9914a7b587576ee31694b3/boto3-1.35.71-py3-none-any.whl", hash = "sha256:e2969a246bb3208122b3c349c49cc6604c6fc3fc2b2f65d99d3e8ccd745b0c16", size = 139177 }, + { url = "https://files.pythonhosted.org/packages/f2/f8/66d12f3d0b7f6df3e3a4797c223b3d750ef88af2d6002f56bf2d2a7810d1/aioboto3-13.3.0-py3-none-any.whl", hash = "sha256:a97d58fa84dc91030be7820724daea59a1603987b535a1d15613eff78c3b3781", size = 34755 }, ] [[package]] -name = "boto3-stubs" -version = "1.35.71" +name = "aiobotocore" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore-stubs" }, - { name = "types-s3transfer" }, + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/85/86243ad2792f8506b567c645d97ece548258203c55bcc165fd5801f4372f/boto3_stubs-1.35.71.tar.gz", hash = "sha256:50e20fa74248c96b3e3498b2d81388585583e38b9f0609d2fa58257e49c986a5", size = 93776 } +sdist = { url = "https://files.pythonhosted.org/packages/06/dc/5a44e1cd5e206b11abf67754d47dabcde4f927bb281b93dabdbf77eba3fd/aiobotocore-2.16.0.tar.gz", hash = "sha256:6d6721961a81570e9b920b98778d95eec3d52a9f83b7844c6c5cfdbf2a2d6a11", size = 107433 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/d1/aedf5f4a92e1e74ee29a4d43084780f2d77aeef3d734e550aa2ab304e1fb/boto3_stubs-1.35.71-py3-none-any.whl", hash = "sha256:4abf357250bdb16d1a56489a59bfc385d132a43677956bd984f6578638d599c0", size = 62964 }, + { url = "https://files.pythonhosted.org/packages/c5/63/c03db9dafb0b3b8a90a1714a1949bc1e7db1d0e2c4062400901da35678fe/aiobotocore-2.16.0-py3-none-any.whl", hash = "sha256:eb3641a7b9c51113adbc33a029441de6201ebb026c64ff2e149c7fa802c9abfc", size = 77781 }, ] [package.optional-dependencies] -essential = [ - { name = "mypy-boto3-cloudformation" }, - { name = "mypy-boto3-dynamodb" }, - { name = "mypy-boto3-ec2" }, - { name = "mypy-boto3-lambda" }, - { name = "mypy-boto3-rds" }, - { name = "mypy-boto3-s3" }, - { name = "mypy-boto3-sqs" }, +boto3 = [ + { name = "boto3" }, ] [[package]] -name = "botocore" -version = "1.35.71" +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.11" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/cf/4bda538c502f9738d6b95ada11603c05ec260807246e15e869fc3ec5de97/aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886", size = 704666 }, + { url = "https://files.pythonhosted.org/packages/46/7b/87fcef2cad2fad420ca77bef981e815df6904047d0a1bd6aeded1b0d1d66/aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2", size = 464057 }, + { url = "https://files.pythonhosted.org/packages/5a/a6/789e1f17a1b6f4a38939fbc39d29e1d960d5f89f73d0629a939410171bc0/aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c", size = 455996 }, + { url = "https://files.pythonhosted.org/packages/b7/dd/485061fbfef33165ce7320db36e530cd7116ee1098e9c3774d15a732b3fd/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a", size = 1682367 }, + { url = "https://files.pythonhosted.org/packages/e9/d7/9ec5b3ea9ae215c311d88b2093e8da17e67b8856673e4166c994e117ee3e/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231", size = 1736989 }, + { url = "https://files.pythonhosted.org/packages/d6/fb/ea94927f7bfe1d86178c9d3e0a8c54f651a0a655214cce930b3c679b8f64/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e", size = 1793265 }, + { url = "https://files.pythonhosted.org/packages/40/7f/6de218084f9b653026bd7063cd8045123a7ba90c25176465f266976d8c82/aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8", size = 1691841 }, + { url = "https://files.pythonhosted.org/packages/77/e2/992f43d87831cbddb6b09c57ab55499332f60ad6fdbf438ff4419c2925fc/aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8", size = 1619317 }, + { url = "https://files.pythonhosted.org/packages/96/74/879b23cdd816db4133325a201287c95bef4ce669acde37f8f1b8669e1755/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c", size = 1641416 }, + { url = "https://files.pythonhosted.org/packages/30/98/b123f6b15d87c54e58fd7ae3558ff594f898d7f30a90899718f3215ad328/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab", size = 1646514 }, + { url = "https://files.pythonhosted.org/packages/d7/38/257fda3dc99d6978ab943141d5165ec74fd4b4164baa15e9c66fa21da86b/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da", size = 1702095 }, + { url = "https://files.pythonhosted.org/packages/0c/f4/ddab089053f9fb96654df5505c0a69bde093214b3c3454f6bfdb1845f558/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853", size = 1734611 }, + { url = "https://files.pythonhosted.org/packages/c3/d6/f30b2bc520c38c8aa4657ed953186e535ae84abe55c08d0f70acd72ff577/aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e", size = 1694576 }, + { url = "https://files.pythonhosted.org/packages/bc/97/b0a88c3f4c6d0020b34045ee6d954058abc870814f6e310c4c9b74254116/aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600", size = 411363 }, + { url = "https://files.pythonhosted.org/packages/7f/23/cc36d9c398980acaeeb443100f0216f50a7cfe20c67a9fd0a2f1a5a846de/aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d", size = 437666 }, + { url = "https://files.pythonhosted.org/packages/49/d1/d8af164f400bad432b63e1ac857d74a09311a8334b0481f2f64b158b50eb/aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9", size = 697982 }, + { url = "https://files.pythonhosted.org/packages/92/d1/faad3bf9fa4bfd26b95c69fc2e98937d52b1ff44f7e28131855a98d23a17/aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194", size = 460662 }, + { url = "https://files.pythonhosted.org/packages/db/61/0d71cc66d63909dabc4590f74eba71f91873a77ea52424401c2498d47536/aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f", size = 452950 }, + { url = "https://files.pythonhosted.org/packages/07/db/6d04bc7fd92784900704e16b745484ef45b77bd04e25f58f6febaadf7983/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104", size = 1665178 }, + { url = "https://files.pythonhosted.org/packages/54/5c/e95ade9ae29f375411884d9fd98e50535bf9fe316c9feb0f30cd2ac8f508/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff", size = 1717939 }, + { url = "https://files.pythonhosted.org/packages/6f/1c/1e7d5c5daea9e409ed70f7986001b8c9e3a49a50b28404498d30860edab6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3", size = 1775125 }, + { url = "https://files.pythonhosted.org/packages/5d/66/890987e44f7d2f33a130e37e01a164168e6aff06fce15217b6eaf14df4f6/aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1", size = 1677176 }, + { url = "https://files.pythonhosted.org/packages/8f/dc/e2ba57d7a52df6cdf1072fd5fa9c6301a68e1cd67415f189805d3eeb031d/aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4", size = 1603192 }, + { url = "https://files.pythonhosted.org/packages/6c/9e/8d08a57de79ca3a358da449405555e668f2c8871a7777ecd2f0e3912c272/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d", size = 1618296 }, + { url = "https://files.pythonhosted.org/packages/56/51/89822e3ec72db352c32e7fc1c690370e24e231837d9abd056490f3a49886/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87", size = 1616524 }, + { url = "https://files.pythonhosted.org/packages/2c/fa/e2e6d9398f462ffaa095e84717c1732916a57f1814502929ed67dd7568ef/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2", size = 1685471 }, + { url = "https://files.pythonhosted.org/packages/ae/5f/6bb976e619ca28a052e2c0ca7b0251ccd893f93d7c24a96abea38e332bf6/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12", size = 1715312 }, + { url = "https://files.pythonhosted.org/packages/79/c1/756a7e65aa087c7fac724d6c4c038f2faaa2a42fe56dbc1dd62a33ca7213/aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5", size = 1672783 }, + { url = "https://files.pythonhosted.org/packages/73/ba/a6190ebb02176c7f75e6308da31f5d49f6477b651a3dcfaaaca865a298e2/aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d", size = 410229 }, + { url = "https://files.pythonhosted.org/packages/b8/62/c9fa5bafe03186a0e4699150a7fed9b1e73240996d0d2f0e5f70f3fdf471/aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99", size = 436081 }, +] + +[[package]] +name = "aioitertools" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/de/38491a84ab323b47c7f86e94d2830e748780525f7a10c8600b67ead7e9ea/aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b", size = 19369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/13/58b70a580de00893223d61de8fea167877a3aed97d4a5e1405c9159ef925/aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796", size = 24345 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, +] + +[[package]] +name = "attrs" +version = "24.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, +] + +[[package]] +name = "boto3" +version = "1.35.81" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, + { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/7b/c3f9babe738d5efeb96bd5b250bafcd733c2fd5d8650d8986daa86ee45a1/botocore-1.35.71.tar.gz", hash = "sha256:f9fa058e0393660c3fe53c1e044751beb64b586def0bd2212448a7c328b0cbba", size = 13238393 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/a5/8e610a7c230326b6a766758ce290233a8d0ec88bef4f5afe09e2313d2def/boto3-1.35.81.tar.gz", hash = "sha256:d2e95fa06f095b8e0c545dd678c6269d253809b2997c30f5ce8a956c410b4e86", size = 111013 } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/49/b821048ae671518c00064a936ca08ab4ef7a715d866c3f0331688febeedd/botocore-1.35.71-py3-none-any.whl", hash = "sha256:fc46e7ab1df3cef66dfba1633f4da77c75e07365b36f03bd64a3793634be8fc1", size = 13034516 }, + { url = "https://files.pythonhosted.org/packages/b4/db/e6bf2a34d7e8440800fcd11f2b42efd4ba18cce56d5a213bb93bd62aaa0e/boto3-1.35.81-py3-none-any.whl", hash = "sha256:742941b2424c0223d2d94a08c3485462fa7c58d816b62ca80f08e555243acee1", size = 139178 }, ] [[package]] -name = "botocore-stubs" -version = "1.35.71" +name = "botocore" +version = "1.35.81" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "types-awscrt" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/0d/a76cdd9457268ad6efedd8abdc9f81511e297d20331fd6dc7de52ffc0701/botocore_stubs-1.35.71.tar.gz", hash = "sha256:c5f7208b20ae19400fa73eb569017f1e372990f7a5505a72116ed6420904f666", size = 40166 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/a8/b44d94c14ee4eb13db6dc549269c79199b43bddd70982e192aefd6ca6279/botocore-1.35.81.tar.gz", hash = "sha256:564c2478e50179e0b766e6a87e5e0cdd35e1bc37eb375c1cf15511f5dd13600d", size = 13460205 } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ce/aaccd20b8c4c90dc688e128ab9d4446403e692866961c979e29ed3fb28a1/botocore_stubs-1.35.71-py3-none-any.whl", hash = "sha256:7e938bb169c28faf05ce14e67bb0b5e5583092ab6ccc9d3d68d698530edb6584", size = 61049 }, + { url = "https://files.pythonhosted.org/packages/1a/ad/00dfec368dd4e957063ed1126b5511238b0900c1014dfe539af93fc0ac29/botocore-1.35.81-py3-none-any.whl", hash = "sha256:a7b13bbd959bf2d6f38f681676aab408be01974c46802ab997617b51399239f7", size = 13265330 }, ] [[package]] -name = "bottle" -version = "0.13.2" +name = "botocore-stubs" +version = "1.35.90" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/97839b95c2a2ea1ca91877a846988f90f4ca16ee42c0bb79e079171c0c06/bottle-0.13.2.tar.gz", hash = "sha256:e53803b9d298c7d343d00ba7d27b0059415f04b9f6f40b8d58b5bf914ba9d348", size = 98472 } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/3b/5b8df11c74fc4bb92ebd2df968a6d981c99302dd2a918d88ee1869acdad4/botocore_stubs-1.35.90.tar.gz", hash = "sha256:c6b294cae436eaaf87dcb717e4348c250ea1fc170336579da114b693663d8e42", size = 41085 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/0a/a5260c758ff813acc6967344339aa7ba15f815575f4d49141685c4345d39/bottle-0.13.2-py2.py3-none-any.whl", hash = "sha256:27569ab8d1332fbba3e400b3baab2227ab4efb4882ff147af05a7c00ed73409c", size = 104053 }, + { url = "https://files.pythonhosted.org/packages/e0/93/6104f6f2729ffc85be0b8f75c6946491dc82684f5bd74202527e778b70f5/botocore_stubs-1.35.90-py3-none-any.whl", hash = "sha256:f7fd78d84f49d28692662b9bdeb4c92f1bf8a5707d0c28c8544399005b02823b", size = 63846 }, ] [[package]] name = "brrr" -version = "0.1.0" +version = "0.0.5" source = { editable = "." } [package.dev-dependencies] dev = [ - { name = "boto3" }, - { name = "boto3-stubs", extra = ["essential"] }, - { name = "bottle" }, + { name = "aioboto3" }, + { name = "aiohttp" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "redis" }, { name = "ruff" }, + { name = "types-aioboto3", extra = ["essential"] }, ] [package.metadata] [package.metadata.requires-dev] dev = [ - { name = "boto3", specifier = ">=1.35.71" }, - { name = "boto3-stubs", extras = ["essential"], specifier = ">=1.35.71" }, - { name = "bottle", specifier = ">=0.13.2" }, + { name = "aioboto3", specifier = ">=13.3.0" }, + { name = "aiohttp", specifier = ">=3.11.11" }, { name = "pyright", specifier = ">=1.1.389" }, { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "redis", specifier = ">=5.2.0" }, { name = "ruff", specifier = ">=0.8.1" }, + { name = "types-aioboto3", extras = ["essential"], specifier = ">=13.3.0.post1" }, ] [[package]] @@ -113,84 +210,108 @@ wheels = [ ] [[package]] -name = "iniconfig" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, -] - -[[package]] -name = "jmespath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, -] - -[[package]] -name = "mypy-boto3-cloudformation" -version = "1.35.64" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/97/04c21049fb94ea9b5dc52cb5350042b822a5e3f8495e06403e442abc510a/mypy_boto3_cloudformation-1.35.64.tar.gz", hash = "sha256:d1a1500df811ac8ebd459640f5b31c14daac784d8a00fc4f67bc6eb391e7b5a8", size = 53575 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/c3/c7de8091b98747e134c68f94442ea906b1ee91e7b3e1831088b708e2da85/mypy_boto3_cloudformation-1.35.64-py3-none-any.whl", hash = "sha256:aba213f3411a65096a8d95633c36e0c57a775ac6ac9ccf1e6fd9bea4002073bc", size = 65168 }, -] - -[[package]] -name = "mypy-boto3-dynamodb" -version = "1.35.60" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/e7/09a49462ea33bd31d8d552ffe84f9fffb12ae16b350a54c9b0f9074d218a/mypy_boto3_dynamodb-1.35.60.tar.gz", hash = "sha256:92eac35c49e9f3ff23a4ad6dee5dc54e410e0c49a98b4d93493c7000ebe74568", size = 45142 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a7/8ec2958a20bef1e6956c7420a4eb23059d982149ed14a7f0c9e7a73de404/mypy_boto3_dynamodb-1.35.60-py3-none-any.whl", hash = "sha256:187915c781f352bc79d35b08a094605515ecc54f30107f629972c3358b864a5c", size = 54183 }, -] - -[[package]] -name = "mypy-boto3-ec2" -version = "1.35.70" +name = "frozenlist" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/a4/a92feffd3ccba3415e1f2cb5e0b482b9bfba885d0a01a4aadf97133243c3/mypy_boto3_ec2-1.35.70.tar.gz", hash = "sha256:93f9ddadac303d63f34cd4c0a60a682c008d655d3b2cfa74d1234fbde9a0b401", size = 379694 } +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/2f/8d75aa08d7dbdb29ad7374a80ee544fbe1801f350fc4b588da147baa4de7/mypy_boto3_ec2-1.35.70-py3-none-any.whl", hash = "sha256:d5b27b79b1749fb10a4eb9508069995a8e4bf2614f4e171224637596403e42c8", size = 371241 }, + { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 }, + { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 }, + { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 }, + { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 }, + { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 }, + { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 }, + { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 }, + { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 }, + { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 }, + { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 }, + { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 }, + { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 }, + { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 }, + { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 }, + { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 }, + { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 }, + { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 }, + { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 }, + { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 }, + { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 }, + { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 }, + { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 }, + { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 }, + { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 }, + { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 }, + { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 }, + { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, + { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, + { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, + { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] [[package]] -name = "mypy-boto3-lambda" -version = "1.35.68" +name = "idna" +version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/2e/b80f84512a1d62a6d8c6d076ad21449bff06c5c4ec69f21617f66ae1167e/mypy_boto3_lambda-1.35.68.tar.gz", hash = "sha256:577a9465ac63ac564efc2755a7e72c28a9d2f496747c1faf242cb13d5017b262", size = 40260 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/75/d6c0381da41cddc89fa8afa7e4d7fd0007cc0fb9de0596d97712cc6894ef/mypy_boto3_lambda-1.35.68-py3-none-any.whl", hash = "sha256:00499898236fe423c9292f77644102d4bd6699b3c16b8c4062eb759c022447f5", size = 47192 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] -name = "mypy-boto3-rds" -version = "1.35.66" +name = "iniconfig" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/e2/0351901e2b48f39c5fc43e9b0d6544f7ac2ff4d09dafc468903fdd5352fa/mypy_boto3_rds-1.35.66.tar.gz", hash = "sha256:0850cb5bddda1853c6ba44bb8dc1bf0d303ea4729f8cdf982d0e4d91f08ab2d9", size = 82939 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/be/d3d5bebcb505fa2437c33a42fa8875ad7f8e76bf67d3e9b2e925643826fc/mypy_boto3_rds-1.35.66-py3-none-any.whl", hash = "sha256:7bfeadfbd361aaf53a5f161c571886d3cadbdf05c15591761280fe6f079ab273", size = 89590 }, + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] [[package]] -name = "mypy-boto3-s3" -version = "1.35.69" +name = "jmespath" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/13/4603c44a16d3ed5de032a1dbdb3f6fcaea2bf82ba46cad85df5b3d6d5498/mypy_boto3_s3-1.35.69.tar.gz", hash = "sha256:97f7944a84a4a49282825bef1483a25680dcdce75da6017745d709d2cf2aa1c0", size = 70301 } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/e9/b2548bfd7358057e102fcf9b0705a40d2b4dce86cfeaf1b604959b44f15e/mypy_boto3_s3-1.35.69-py3-none-any.whl", hash = "sha256:11a34259983e09d67e4d3a322fd47904a006bbfff19984e4e36a77e30f2014bb", size = 77391 }, + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, ] [[package]] -name = "mypy-boto3-sqs" -version = "1.35.0" +name = "multidict" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/e5/e8228deac8720ddd152c8ff8f55fa5ef27f1c256835dd98063cd754570c7/mypy_boto3_sqs-1.35.0.tar.gz", hash = "sha256:61752f1c2bf2efa3815f64d43c25b4a39dbdbd9e472ae48aa18d7c6d2a7a6eb8", size = 22253 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/46/5ad44cf9d4725a4530b9ea9d0fdb01893d2f5fa7b23e51b0d483635350ff/mypy_boto3_sqs-1.35.0-py3-none-any.whl", hash = "sha256:9fd6e622ed231c06f7542ba6f8f0eea92046cace24defa95d0d0ce04e7caee0c", size = 33032 }, + { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 }, + { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 }, + { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 }, + { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 }, + { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 }, + { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 }, + { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 }, + { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 }, + { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 }, + { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 }, + { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 }, + { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 }, + { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 }, + { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 }, + { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 }, + { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 }, + { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 }, + { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 }, + { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 }, + { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 }, + { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 }, + { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 }, + { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 }, + { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 }, + { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 }, + { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 }, + { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, + { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, + { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, ] [[package]] @@ -220,6 +341,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "propcache" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/28/1d205fe49be8b1b4df4c50024e62480a442b1a7b818e734308bb0d17e7fb/propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", size = 79588 }, + { url = "https://files.pythonhosted.org/packages/21/ee/fc4d893f8d81cd4971affef2a6cb542b36617cd1d8ce56b406112cb80bf7/propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", size = 45825 }, + { url = "https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", size = 45357 }, + { url = "https://files.pythonhosted.org/packages/7f/14/7ae06a6cf2a2f1cb382586d5a99efe66b0b3d0c6f9ac2f759e6f7af9d7cf/propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", size = 241869 }, + { url = "https://files.pythonhosted.org/packages/cc/59/227a78be960b54a41124e639e2c39e8807ac0c751c735a900e21315f8c2b/propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", size = 247884 }, + { url = "https://files.pythonhosted.org/packages/84/58/f62b4ffaedf88dc1b17f04d57d8536601e4e030feb26617228ef930c3279/propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", size = 248486 }, + { url = "https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", size = 243649 }, + { url = "https://files.pythonhosted.org/packages/ed/bc/4f7aba7f08f520376c4bb6a20b9a981a581b7f2e385fa0ec9f789bb2d362/propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", size = 229103 }, + { url = "https://files.pythonhosted.org/packages/fe/d5/04ac9cd4e51a57a96f78795e03c5a0ddb8f23ec098b86f92de028d7f2a6b/propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", size = 226607 }, + { url = "https://files.pythonhosted.org/packages/e3/f0/24060d959ea41d7a7cc7fdbf68b31852331aabda914a0c63bdb0e22e96d6/propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", size = 221153 }, + { url = "https://files.pythonhosted.org/packages/77/a7/3ac76045a077b3e4de4859a0753010765e45749bdf53bd02bc4d372da1a0/propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", size = 222151 }, + { url = "https://files.pythonhosted.org/packages/e7/af/5e29da6f80cebab3f5a4dcd2a3240e7f56f2c4abf51cbfcc99be34e17f0b/propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", size = 233812 }, + { url = "https://files.pythonhosted.org/packages/8c/89/ebe3ad52642cc5509eaa453e9f4b94b374d81bae3265c59d5c2d98efa1b4/propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", size = 238829 }, + { url = "https://files.pythonhosted.org/packages/e9/2f/6b32f273fa02e978b7577159eae7471b3cfb88b48563b1c2578b2d7ca0bb/propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", size = 230704 }, + { url = "https://files.pythonhosted.org/packages/5c/2e/f40ae6ff5624a5f77edd7b8359b208b5455ea113f68309e2b00a2e1426b6/propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", size = 40050 }, + { url = "https://files.pythonhosted.org/packages/3b/77/a92c3ef994e47180862b9d7d11e37624fb1c00a16d61faf55115d970628b/propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", size = 44117 }, + { url = "https://files.pythonhosted.org/packages/0f/2a/329e0547cf2def8857157f9477669043e75524cc3e6251cef332b3ff256f/propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", size = 77002 }, + { url = "https://files.pythonhosted.org/packages/12/2d/c4df5415e2382f840dc2ecbca0eeb2293024bc28e57a80392f2012b4708c/propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", size = 44639 }, + { url = "https://files.pythonhosted.org/packages/d0/5a/21aaa4ea2f326edaa4e240959ac8b8386ea31dedfdaa636a3544d9e7a408/propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", size = 44049 }, + { url = "https://files.pythonhosted.org/packages/4e/3e/021b6cd86c0acc90d74784ccbb66808b0bd36067a1bf3e2deb0f3845f618/propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", size = 224819 }, + { url = "https://files.pythonhosted.org/packages/3c/57/c2fdeed1b3b8918b1770a133ba5c43ad3d78e18285b0c06364861ef5cc38/propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", size = 229625 }, + { url = "https://files.pythonhosted.org/packages/9d/81/70d4ff57bf2877b5780b466471bebf5892f851a7e2ca0ae7ffd728220281/propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", size = 232934 }, + { url = "https://files.pythonhosted.org/packages/3c/b9/bb51ea95d73b3fb4100cb95adbd4e1acaf2cbb1fd1083f5468eeb4a099a8/propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", size = 227361 }, + { url = "https://files.pythonhosted.org/packages/f1/20/3c6d696cd6fd70b29445960cc803b1851a1131e7a2e4ee261ee48e002bcd/propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", size = 213904 }, + { url = "https://files.pythonhosted.org/packages/a1/cb/1593bfc5ac6d40c010fa823f128056d6bc25b667f5393781e37d62f12005/propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", size = 212632 }, + { url = "https://files.pythonhosted.org/packages/6d/5c/e95617e222be14a34c709442a0ec179f3207f8a2b900273720501a70ec5e/propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", size = 207897 }, + { url = "https://files.pythonhosted.org/packages/8e/3b/56c5ab3dc00f6375fbcdeefdede5adf9bee94f1fab04adc8db118f0f9e25/propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", size = 208118 }, + { url = "https://files.pythonhosted.org/packages/86/25/d7ef738323fbc6ebcbce33eb2a19c5e07a89a3df2fded206065bd5e868a9/propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", size = 217851 }, + { url = "https://files.pythonhosted.org/packages/b3/77/763e6cef1852cf1ba740590364ec50309b89d1c818e3256d3929eb92fabf/propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", size = 222630 }, + { url = "https://files.pythonhosted.org/packages/4f/e9/0f86be33602089c701696fbed8d8c4c07b6ee9605c5b7536fd27ed540c5b/propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", size = 216269 }, + { url = "https://files.pythonhosted.org/packages/cc/02/5ac83217d522394b6a2e81a2e888167e7ca629ef6569a3f09852d6dcb01a/propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", size = 39472 }, + { url = "https://files.pythonhosted.org/packages/f4/33/d6f5420252a36034bc8a3a01171bc55b4bff5df50d1c63d9caa50693662f/propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", size = 43363 }, + { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 }, +] + [[package]] name = "pyright" version = "1.1.389" @@ -248,6 +410,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/82fcb4ee47d66d99f6cd1efc0b11b2a25029f303c599a5afda7c1bca4254/pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", size = 53298 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/56/2ee0cab25c11d4e38738a2a98c645a8f002e2ecf7b5ed774c70d53b92bb1/pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3", size = 19245 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -308,20 +482,120 @@ wheels = [ [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "types-aioboto3" +version = "13.3.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-aiobotocore" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/77/6c6e3e391a827abbdd02a156ab51c0f98682aeb8695c1075d70682b9290b/types_aioboto3-13.3.0.post1.tar.gz", hash = "sha256:796f668c859a8e74f813bc5fd3d24b439a9b484fa86813fbb9d203cdfd432ead", size = 80424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/04/adc92c4a261176c223ce8233119f3cb96fa475ba39ed901076f627cf005d/types_aioboto3-13.3.0.post1-py3-none-any.whl", hash = "sha256:c06b1a8657d74b90fd3e0d57e9bd34f83458991bf1cb2b09b97879f18faece8b", size = 42098 }, +] + +[package.optional-dependencies] +essential = [ + { name = "types-aiobotocore-cloudformation" }, + { name = "types-aiobotocore-dynamodb" }, + { name = "types-aiobotocore-ec2" }, + { name = "types-aiobotocore-lambda" }, + { name = "types-aiobotocore-rds" }, + { name = "types-aiobotocore-s3" }, + { name = "types-aiobotocore-sqs" }, +] + +[[package]] +name = "types-aiobotocore" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/8a/76aebc6bce4d3df4d52ec38c34a8051b8526598da1d99e10ca5eb2f2c616/types_aiobotocore-2.16.1.tar.gz", hash = "sha256:92421aad4eebd3cd359a94f71ef13623ddd19b187b42cbc430734595dc4b6a33", size = 84335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/eb/37002625bbdb1ffc9215d99d339a84e67e39d5c5e33a3b4ce785ddcec61c/types_aiobotocore-2.16.1-py3-none-any.whl", hash = "sha256:5214ac8cdc538c3a4031772e708d7ecee3ed37bb6cb8ebe0aa932500e225ec08", size = 51602 }, +] + +[[package]] +name = "types-aiobotocore-cloudformation" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/3e/1ed86c3878b26e79fceb28b2dec19b5b6f9614a474947c687a2bcf7bd3de/types_aiobotocore_cloudformation-2.16.1.tar.gz", hash = "sha256:4d26222cfb452e89059fabc3d73b21ed7c4fe4f8f4760085e5ceb0e7577a7350", size = 54398 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/67/5f283069dba037ab6523b0029915bfbbbd6e0d8f7b0488e416fc223558dd/types_aiobotocore_cloudformation-2.16.1-py3-none-any.whl", hash = "sha256:c590bfad7f6836e4780d6a0f0e60d2b239cf4ba9fa77c310967dd2dd97881152", size = 66693 }, +] + +[[package]] +name = "types-aiobotocore-dynamodb" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/64/9a483e1b73291cba0b9ec52ac5b6045ede53b0dfa88c11f38f7d20472b70/types_aiobotocore_dynamodb-2.16.1.tar.gz", hash = "sha256:f6ce4cf40dcaf1a02714317309b52b35b1603957be5679055c94b0d398f66c97", size = 46926 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/7a/67cce01bdb633b710d9f85433afdd1dc66a68088db066a5b0731c3275d0b/types_aiobotocore_dynamodb-2.16.1-py3-none-any.whl", hash = "sha256:32b4a58967530bfec6aa0becfbac4bc1c97e383d38fcab1bda511390882c79d4", size = 56566 }, +] + +[[package]] +name = "types-aiobotocore-ec2" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/9d/815864a6b7db8d230b9b225b4d5926eb609af4f5fba70c34705fb2859ef4/types_aiobotocore_ec2-2.16.1.tar.gz", hash = "sha256:146596ada249e4f92f60aed3bfc1887bb2e7e5a0591fdd1397c53af62c65ac72", size = 389861 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/50/292efe4b1604073a089204c1dfc828c502d3debe649029cbe33e32c3cff7/types_aiobotocore_ec2-2.16.1-py3-none-any.whl", hash = "sha256:42a8dfe287ae7a9e4a3749d6cd795badcd6e98727d102e22865a72a1a4a144ed", size = 380136 }, +] + +[[package]] +name = "types-aiobotocore-lambda" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/10/249d400d0aa7586820db472426cbcf69a5793b83b36fc23d3d03179f27c4/types_aiobotocore_lambda-2.16.1.tar.gz", hash = "sha256:8da16df6ff849799483356c4d6fca087f33e14b236e1f05f60aa0bd054ffd770", size = 40961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/f5/75d3e4608706bb6a136e1c25ddf9dd4c9b33d08ec48eb6d98a33844cceb9/types_aiobotocore_lambda-2.16.1-py3-none-any.whl", hash = "sha256:efa26e1f67bd38de0fb9843bc701c55943ca0befb4e6fba5fc6e84dfb8482018", size = 48065 }, +] + +[[package]] +name = "types-aiobotocore-rds" +version = "2.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/78/506c0cddbc76dec80dad08ae4be3c9ba96725f0a09c97f466285d90ff54c/types_aiobotocore_rds-2.16.1.tar.gz", hash = "sha256:b758ad707976e1adf5db47a870ef42fbaa98d2438ad45cb44f1532ff42a6e5a2", size = 83864 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, + { url = "https://files.pythonhosted.org/packages/68/a5/7783c3b028f64c0c67fe4970d7f04354346e4809f3f0cb9162c65dffbadc/types_aiobotocore_rds-2.16.1-py3-none-any.whl", hash = "sha256:1922b4ba11e57dcc8a9beffdf8437d83535ce4b3da705450994c04d8ed5945f9", size = 90629 }, +] + +[[package]] +name = "types-aiobotocore-s3" +version = "2.16.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/bd/ee6bb09b44e9c546a04d911892b64924f469765e7e2aff7940d104fa332b/types_aiobotocore_s3-2.16.1.post1.tar.gz", hash = "sha256:4373c33fc44c70da600283429e2f5de662778b6265ee446e285e8b43b9acc5a5", size = 72992 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/cc/0cfbce6de6a98380d1a74c4b2dc767970f433d0dd4be82a74a3801b06412/types_aiobotocore_s3-2.16.1.post1-py3-none-any.whl", hash = "sha256:20bf7524358cfd88cb327b43c0c247196ec7f9df8c1c016f7869ad2ac8a7ce33", size = 80469 }, +] + +[[package]] +name = "types-aiobotocore-sqs" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/a1/8e8fc7b7e27b12b502e12711b69eda201b6e2cf048a32a0d2a7fb02b7b6f/types_aiobotocore_sqs-2.16.1.tar.gz", hash = "sha256:2b9ff9b3007e4e11ecd460605cd0ee936bc1b602659526dabb8ff6379aa072a1", size = 23293 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/74/8a80f68f23b1a4be4ba316b8b1f33ffe5814aa109c2858da6c64a2279efa/types_aiobotocore_sqs-2.16.1-py3-none-any.whl", hash = "sha256:9903074790d715b52ac6929331c772c01ad78a77e1dfaa5b82aadda7b5e834ed", size = 33775 }, ] [[package]] name = "types-awscrt" -version = "0.23.1" +version = "0.23.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5d/9a/534fdab439cc6c6d3a9756dd5883b71cb3cb0f7359c6119be79770db1210/types_awscrt-0.23.1.tar.gz", hash = "sha256:a20b425dabb258bc3d07a5e7de503fd9558dd1542d72de796e74e402c6d493b2", size = 14942 } +sdist = { url = "https://files.pythonhosted.org/packages/dd/97/c62253e8ed65562c67b2138339444cc77507c8ee01c091e02ead1311e4b8/types_awscrt-0.23.6.tar.gz", hash = "sha256:405bce8c281f9e7c6c92a229225cc0bf10d30729a6a601123213389bd524b8b1", size = 15124 } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/34/ab41dec9a871665330daf279d3fb5eb0eb0b2bd9e6a8ad8faac10496fed3/types_awscrt-0.23.1-py3-none-any.whl", hash = "sha256:0d362a5d62d68ca4216f458172f41c1123ec04791d68364de8ee8b61b528b262", size = 18452 }, + { url = "https://files.pythonhosted.org/packages/21/f1/0f0869d35c1b746df98d60016f898eb49db208747a4ed2de81b58f48ecd8/types_awscrt-0.23.6-py3-none-any.whl", hash = "sha256:fbf9c221af5607b24bf17f8431217ce8b9a27917139edbc984891eb63fd5a593", size = 19025 }, ] [[package]] @@ -344,9 +618,91 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "wrapt" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/24/a1/fc03dca9b0432725c2e8cdbf91a349d2194cf03d8523c124faebe581de09/wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801", size = 55542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/82/518605474beafff11f1a34759f6410ab429abff9f7881858a447e0d20712/wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/80/6c/17c3b2fed28edfd96d8417c865ef0b4c955dc52c4e375d86f459f14340f1/wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504", size = 88622 }, + { url = "https://files.pythonhosted.org/packages/4a/11/60ecdf3b0fd3dca18978d89acb5d095a05f23299216e925fcd2717c81d93/wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451", size = 80920 }, + { url = "https://files.pythonhosted.org/packages/d2/50/dbef1a651578a3520d4534c1e434989e3620380c1ad97e309576b47f0ada/wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1", size = 89170 }, + { url = "https://files.pythonhosted.org/packages/44/a2/78c5956bf39955288c9e0dd62e807b308c3aa15a0f611fbff52aa8d6b5ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106", size = 86748 }, + { url = "https://files.pythonhosted.org/packages/99/49/2ee413c78fc0bdfebe5bee590bf3becdc1fab0096a7a9c3b5c9666b2415f/wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada", size = 79734 }, + { url = "https://files.pythonhosted.org/packages/c0/8c/4221b7b270e36be90f0930fe15a4755a6ea24093f90b510166e9ed7861ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4", size = 87552 }, + { url = "https://files.pythonhosted.org/packages/4c/6b/1aaccf3efe58eb95e10ce8e77c8909b7a6b0da93449a92c4e6d6d10b3a3d/wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635", size = 36647 }, + { url = "https://files.pythonhosted.org/packages/b3/4f/243f88ac49df005b9129194c6511b3642818b3e6271ddea47a15e2ee4934/wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7", size = 38830 }, + { url = "https://files.pythonhosted.org/packages/67/9c/38294e1bb92b055222d1b8b6591604ca4468b77b1250f59c15256437644f/wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/78/b6/76597fb362cbf8913a481d41b14b049a8813cd402a5d2f84e57957c813ae/wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393", size = 88608 }, + { url = "https://files.pythonhosted.org/packages/bc/69/b500884e45b3881926b5f69188dc542fb5880019d15c8a0df1ab1dfda1f7/wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4", size = 80879 }, + { url = "https://files.pythonhosted.org/packages/52/31/f4cc58afe29eab8a50ac5969963010c8b60987e719c478a5024bce39bc42/wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b", size = 89119 }, + { url = "https://files.pythonhosted.org/packages/aa/9c/05ab6bf75dbae7a9d34975fb6ee577e086c1c26cde3b6cf6051726d33c7c/wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721", size = 86778 }, + { url = "https://files.pythonhosted.org/packages/0e/6c/4b8d42e3db355603d35fe5c9db79c28f2472a6fd1ccf4dc25ae46739672a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90", size = 79793 }, + { url = "https://files.pythonhosted.org/packages/69/23/90e3a2ee210c0843b2c2a49b3b97ffcf9cad1387cb18cbeef9218631ed5a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a", size = 87606 }, + { url = "https://files.pythonhosted.org/packages/5f/06/3683126491ca787d8d71d8d340e775d40767c5efedb35039d987203393b7/wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045", size = 36651 }, + { url = "https://files.pythonhosted.org/packages/f1/bc/3bf6d2ca0d2c030d324ef9272bea0a8fdaff68f3d1fa7be7a61da88e51f7/wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838", size = 38835 }, + { url = "https://files.pythonhosted.org/packages/ce/b5/251165c232d87197a81cd362eeb5104d661a2dd3aa1f0b33e4bf61dda8b8/wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b", size = 40146 }, + { url = "https://files.pythonhosted.org/packages/89/33/1e1bdd3e866eeb73d8c4755db1ceb8a80d5bd51ee4648b3f2247adec4e67/wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379", size = 113444 }, + { url = "https://files.pythonhosted.org/packages/9f/7c/94f53b065a43f5dc1fbdd8b80fd8f41284315b543805c956619c0b8d92f0/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d", size = 101246 }, + { url = "https://files.pythonhosted.org/packages/62/5d/640360baac6ea6018ed5e34e6e80e33cfbae2aefde24f117587cd5efd4b7/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f", size = 109320 }, + { url = "https://files.pythonhosted.org/packages/e3/cf/6c7a00ae86a2e9482c91170aefe93f4ccda06c1ac86c4de637c69133da59/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c", size = 110193 }, + { url = "https://files.pythonhosted.org/packages/cd/cc/aa718df0d20287e8f953ce0e2f70c0af0fba1d3c367db7ee8bdc46ea7003/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b", size = 100460 }, + { url = "https://files.pythonhosted.org/packages/f7/16/9f3ac99fe1f6caaa789d67b4e3c562898b532c250769f5255fa8b8b93983/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab", size = 106347 }, + { url = "https://files.pythonhosted.org/packages/64/85/c77a331b2c06af49a687f8b926fc2d111047a51e6f0b0a4baa01ff3a673a/wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf", size = 37971 }, + { url = "https://files.pythonhosted.org/packages/05/9b/b2469f8be9efed24283fd7b9eeb8e913e9bc0715cf919ea8645e428ab7af/wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a", size = 40755 }, + { url = "https://files.pythonhosted.org/packages/4b/d9/a8ba5e9507a9af1917285d118388c5eb7a81834873f45df213a6fe923774/wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371", size = 23592 }, +] + +[[package]] +name = "yarl" +version = "1.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, + { url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644 }, + { url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962 }, + { url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795 }, + { url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368 }, + { url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314 }, + { url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987 }, + { url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914 }, + { url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765 }, + { url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444 }, + { url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760 }, + { url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484 }, + { url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864 }, + { url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537 }, + { url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861 }, + { url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097 }, + { url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399 }, + { url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789 }, + { url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144 }, + { url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974 }, + { url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587 }, + { url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386 }, + { url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421 }, + { url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384 }, + { url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689 }, + { url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453 }, + { url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872 }, + { url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497 }, + { url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981 }, + { url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229 }, + { url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383 }, + { url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152 }, + { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 }, + { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, ]