diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1ac349b..5497f97 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,3 +59,27 @@ jobs: working-directory: jetstreamext - run: uv run --python=3.10 pytest . working-directory: jetstreamext + + jetstreampcg-checks: + if: ${{ ! github.event.pull_request.draft == true }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - uses: actions/setup-python@v6 + with: + python-version: | + 3.10 + 3.13 + - run: uv lock --check + working-directory: jetstreampcg + - run: uv run --python=3.13 ruff format --check . + working-directory: jetstreampcg + - run: uv run --python=3.13 ruff check . + working-directory: jetstreampcg + - run: uv run --python=3.13 ty check . + working-directory: jetstreampcg + - run: uv run --python=3.13 pytest . + working-directory: jetstreampcg + - run: uv run --python=3.10 pytest . + working-directory: jetstreampcg diff --git a/.github/workflows/publish-jetstreampcg.yaml b/.github/workflows/publish-jetstreampcg.yaml new file mode 100644 index 0000000..04a5adb --- /dev/null +++ b/.github/workflows/publish-jetstreampcg.yaml @@ -0,0 +1,27 @@ +name: publish-jetstreampcg + +on: + push: + tags: + - jetstreampcg-v* + +permissions: + contents: write + id-token: write + +jobs: + jetstreampcg-publish: + if: ${{ ! github.event.pull_request.draft == true }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - uses: actions/setup-python@v6 + with: + python-version: | + 3.13 + - run: uv build + working-directory: jetstreampcg + - if: ${{ ! github.event.release.prerelease }} + run: uv publish + working-directory: jetstreampcg diff --git a/jetstreampcg/CHANGELOG.md b/jetstreampcg/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/jetstreampcg/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/jetstreampcg/CONTRIBUTING.md b/jetstreampcg/CONTRIBUTING.md new file mode 100644 index 0000000..905c64a --- /dev/null +++ b/jetstreampcg/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Checks that should pass + +- `uv run ruff format .` +- `uv run ruff check --fix .` +- `uv run ty check .` +- `uv run basedpyright .` +- `uv run pytest .` +- `uv run --python=3.10 pytest .` diff --git a/jetstreampcg/NOTICE b/jetstreampcg/NOTICE new file mode 120000 index 0000000..7e1b82f --- /dev/null +++ b/jetstreampcg/NOTICE @@ -0,0 +1 @@ +../NOTICE \ No newline at end of file diff --git a/jetstreampcg/README.md b/jetstreampcg/README.md new file mode 100644 index 0000000..9a4a4ac --- /dev/null +++ b/jetstreampcg/README.md @@ -0,0 +1,97 @@ +# Partitioned Consumer Groups + +Initial implementation of a client-side partitioned consumer group feature for NATS streams leveraging some of the new features introduced in `nats-server` version 2.11. + +Note that post 2.11 versions of `nats-server` may include new features related to the consumer group use case that could render this client-side library unneeded (or make much smaller) + +## Overview + +This library enables the parallelization through partitioning of the consumption of messages from a stream while ensuring a strict order of not just delivery but also successful consumption of the messages using all or parts of the message's subject as a partitioning key. + +In JetStream terms, strictly ordered consumption is achieved when you set the consumer's 'max acks pending' value to 1. However, setting this on a JetStream consumer has the very unfortunate side effect of being very low throughput (limited by the network latency and processing speed) and not being horizontally scalable: only one message is being delivered and processed synchronously at a time from that JetStream consumer, no matter how many instances of the consuming application are deployed. + +The library allows the creation of 'consumer groups' on Stream, where each 'member' of the consumer group can consume from the group in parallel (with max acks pending 1 if needed), with the guarantee that in no way more than one message for a particular key can be consumed at the same time. Client applications wanting to consume messages from the group simply do so using a 'member name' and providing a callback. Even if more than one instance of a member is deployed, only one of those instances will be delivered messages at a time. + +The library takes care of the partitioning and the mapping of the partitions between the members of the group, the idea being that it is mostly transparent to the consuming application's developers who only need to join a consumer group, providing a member name and a callback to process and acknowledge the message when successfully processed. + +NATS Partitioned consumer groups come in two flavors: *elastic* and *static*. + +***Static*** partitioned consumer groups assume that the stream already has a partition number present as the first token of the message's subjects (something that can be done automatically when messages are stored into to the stream by setting a subject transform for the stream). You can only create and delete static consumer groups. Any change to the consumer group's config in the KV bucket will cause all the member instances for all members of the group to stop consuming. + +***Elastic*** partitioned consumer groups on the other hand are implemented differently: the stream doesn't need to already contain a partition number subject token and you can administratively add and drop members from the consumer group's config whenever you want without having to delete and re-create the consumer (like you have to with static consumer groups). + +***In both cases*** +In both cases you must specify when creating the consumer group the maximum number of members for the group (which is actually the number of partitions used when partitioning the messages), plus a list of "members" (named instances of the consuming application). The library takes care of distributing the members over the list of partitions using either a 'balanced' distribution (the partitions are evenly distributed between the members) or 'mappings' (where you assign administratively the mappings of partitions to the members). The membership list or mappings must be specified once at consumer group creation time for static consumer groups, but can be changed at any time for elastic consumer groups. + +Each consumer groups has a configuration which is stored in a KV bucket (named `static-consumer-groups` or `elastic-consumer-groups`). + +### Static + +Static consumer groups operate on a stream where the partition number has already been inserted in the subject as the first token of the messages. In this mode of operation, the library creates JetStream consumers (one per member of the group) directly on the stream. This is not elastic: you create the consumer with a list of members once, and you can not adjust that membership list or mapping for the life of the consumer group (if you want to change the mapping, up to you to delete and re-create the static partitioned consumer group, and to figure out which sequence number you may want this new static partitioned consumer group to start from). + +### Elastic + +Elastic consumer groups operate on any stream, the messages in the stream do not have the partition number present in their subjects. The membership list (or mapping) for the consumer can be adjusted administratively at any time and up to the max number of members defined initially. The consumer group library in this case creates a new work-queue stream that sources from the stream, inserting the partition number subject token on the way. The consumer group library takes care of creating this sourced stream and managing all the consumers on this stream according to the current membership, the developer only needs to provide a stream name, consumer group name and a member name and callback and make sure to ack the messages. You can specify (at creation time) a maximum size (in number of messages or bytes) for this working queue stream, but be aware that once this stream has reached its limit, it will pause the sourcing for at least 1 second (expecting messages to be consumed from the consumer group, thereby making room for more messages to be sourced) so you will want to set this value to more than 1 second's worth of message consumption by the clients of the consumer group or this could result in small delays in the consumption of messages from the consumer group. + +### High availability + +You can deploy and run multiple instances of the consuming application using the same member name, in that case only one of the running instances of the member will be 'pinned' and have messages delivered to it (thereby the other instances are effectively in hot standby). There are functions (`ElasticMemberStepDown()` and `StaticMemberStepDown`) to force a change of the currently pinned member instance. + +### Using Partitioned Consumer Groups + +For the client application programmer, there is one basic functionality exposed by both static and elastic partitioned consumer groups: join and consume messages (when selected) from a named consumer group on a stream by specifying a _member name_, a regular JetStream consumer config, and a _callback_. The library takes care of stripping the partition number token from the subject such that you can use any existing callback code you may already have as is. + +There are also administrative functions to create and delete consumer groups, plus, in the case of elastic consumer groups only, the ability to add/drop members or to change the custom member to partition mappings on an existing elastic consumer group. + +### CLI + +Included is a small command line interface tool, located in the `cg` directory, that allows you to manage consumer groups, as well as test or demonstrate the functionality, and which can be registered as a plugin with the `nats` CLI tool (e.g. `nats plugins register cg /path/to/go/bin/cg`). + +This `cg` CLI tool can be used by passing it commands and arguments directly, or with an interactive prompt using the `prompt` command (e.g. `cg static prompt`). + +### Demo walkthrough + +#### Static + +Create a stream "foo" that automatically partitions over 10 partitions using `static_stream_setup.sh`, then generate some traffic (a new message every 10ms) for that stream using `generate_traffic.sh`. + +Create a static consumer group named "cg" on the stream in question, with two members defined called "m1" and "m2": `cg static create balanced foo cg 10 '>' m1 m2` + +Start consuming messages with a simulated processing time of 20ms from an instance of member "m1": `cg static consume foo cg m1 --sleep 25ms`. Run in another window cg again to consume as member m2 a second, run multiple instances of members m1 and m2, kill the active one (the one receiving messages) and watch as one of the other instances takes over. + +#### Elastic + +Create a stream 'foo' that captures messages on the subjects `foo.*`, then generate some traffic (a new message every 10ms) for that stream using `generate_traffic.sh`. + +Create an elastic consumer group named "cg", partitioning over 10 partitions using the second token (first `*` wildcard in the filter "foo.*") in the subject as the partitioning key: `cg elastic create foo cg 10`. + +At this point the elastic consumer group is created, but no members have been added to it yet. But you can start instances of your consuming members already (e.g. `cg elastic consume foo cg m1` for an instance of a member "m1"), for example start instances of members "m1", "m2" and "m3". At this point none of those members are receiving messages. + +Add "m1" and "m2" to the membership: `cg elastic add foo cg m1 m2`, see how they start receiving messages. Then drop "m1" from the membership `cg elastic drop foo cg m1`, add it again, and each time watch as the consumer starts and stops receiving messages, run another consumer "m3" and add/drop it from the membership, etc... + +As soon as the elastic consumer group is created, you can start instances of consuming clients (e.g. `cg elastic consume foo cg m1`), and they will start to consume messages as soon as (and as long as) they are in the group's membership. + +#### Example + +To start consuming from a static consumer group, you call `pcgroups.StaticConsume`. To start consuming from an elastic consumer group you call `pcgroups.ElasticConsume`. These calls will return an error and a `ConsumerGroupConsumeContext`. Assuming no error is returned,this will create a Go routine that handles consumption and monitoring for changes in the consumer group's config. + +e.g. for static +```golang +consumerGroupContext, err = pcgroups.StaticConsume(myContext, nc, streamName, consumerGroupName, memberName, messageHandler, config) +``` +The arguments are: +- `myContext` is a Golang `context.Context` which is going to be used only for the operations that are part of joining the consumer group. You must use `Stop()` on the `ConsumerGroupContext` being returned to stop the consumption. +- `nc` is a NATS connection object. +- `streamName` is the name of the Stream on which the consumer group has been created. +- `consumerGroupName` is the name of the consumer group that has been created on the stream. +- `memberName` is the name of the member you want to join the consumer group as. +- `messageHandler` is a callback function that gets invoked and passed the messages for consumption. Note that if you are using an elastic consumer group you _must_ explicitly acknowledge (positively or negatively) the message in your callback. +- `config` is a regular JetStream consumer config to use by the library as a template when actually creating the JetStream consumers. For elastic consumers the acknowledgement policy must be explicit. For static consumer groups, it doesn't have to, but if you want to do strictly one at a time processing, you will need to use explicit acks in order for max acks pending 1 to apply. Note that this configuration being used as template means that some of the values will be overwritten and can be left empty (e.g. names and durable names, filters, idle timeouts) or will be overwritten if they are too small (as there is a relationship that must be maintained between the ack wait time, the consumer fetch time out, and the pinned TTL values to avoid 'flapping' of the pinned client which means that lower values will cause in increase in load on the infrastructure because of the added overhead. At this point the ack wait time must be at least 6 seconds, but this may well change in the future and the community users are encouraged to give feed back on this implementation detail). + +Consumption stops either when some error is encountered (for example, any change to the consumer group's config in the case of a static consumer group), the consumer group's config gets deleted, or you invoke `Stop()` on the `ConsumerGroupConsumeContext`. Invoking `Done()` on the `ConsumerGroupConsumeContext` will return a channel on which you can receive the error code indicating why the consumption was stopped. This error code will be `nil` if the consumption terminates normally (due to the consumer group getting deleted or `Stop()` being invoked on the `ConsumerGroupConsumeContext`). + +You can look at the `cg` CLI tool's source code for examples of how to create and consume for both static and elastic consumer groups. + +## Requirements + +Note: partitioned consumer groups require NATS server version 2.11 or above. diff --git a/jetstreampcg/examples.py b/jetstreampcg/examples.py new file mode 100644 index 0000000..467fbf5 --- /dev/null +++ b/jetstreampcg/examples.py @@ -0,0 +1,7 @@ +"""jetstreampcg example usage. + +1. Ensure a local nats server is running on port 4222. +2. run `uv run examples.py` +""" + +# TODO: impl diff --git a/jetstreampcg/pyproject.toml b/jetstreampcg/pyproject.toml new file mode 100644 index 0000000..1d84663 --- /dev/null +++ b/jetstreampcg/pyproject.toml @@ -0,0 +1,95 @@ +[project] +name = "jetstreampcg" +version = "0.1.0" +description = "JetStream partitioned consumer groups is an implementation of a client-side partitioned consumer group feature for NATS streams." +readme = "README.md" +license = "Apache-2.0" +license-files = ["NOTICE"] +authors = [ + { name = "Oliver Lambson", email = "oliverlambson@gmail.com" } +] +requires-python = ">=3.10" +dependencies = [ + "nats-py>=2.12.0", +] +classifiers = [ + 'License :: OSI Approved :: Apache Software License', + 'Intended Audience :: Developers', + 'Development Status :: 2 - Pre-Alpha', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13' +] + +[project.urls] +"Homepage" = "https://github.com/oliverlambson/orbit.py" +"Bug Tracker" = "https://github.com/oliverlambson/orbit.py/issues" + +[build-system] +requires = ["uv_build>=0.8.15,<0.9.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "basedpyright>=1.31.4", + "pytest>=8.4.2", + "pytest-asyncio>=1.1.0", + "ruff>=0.12.12", + "testcontainers>=4.12.0", + "ty>=0.0.1a20", +] + +[tool.uv.build-backend] +source-exclude = ["examples.py"] + +[tool.uv.sources] +nats-py = { git = "https://www.github.com/oliverlambson/nats.py", subdirectory = "nats", rev = "1181018c80476413ade3f8849980afa3c8835ebd" } + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + +[tool.ty.rules] +division-by-zero = "warn" +possibly-unresolved-reference = "warn" +unused-ignore-comment = "warn" + +[tool.ty.terminal] +error-on-warning = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "TD", # todos + "PERF", # performance + "RUF", # ruff + "ASYNC", # flake8-async + "S", # bandit + "BLE", # blind except + "FBT", # boolean trap + "A", # shadowing builtins + "DTZ", # datetime timezone + "EM", # error message + "FA", # future annotations + "G", # logging format +] +ignore = [ + "E501", # line too long (handled by ruff format) + "W505", # line too long (handled by ruff format) + "TD002", # todo author + "TD003", # todo link + "S101", # use of assert +] + +[tool.ruff.format] +docstring-code-format = true diff --git a/jetstreampcg/src/jetstreampcg/__init__.py b/jetstreampcg/src/jetstreampcg/__init__.py new file mode 100644 index 0000000..b5af0d8 --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/__init__.py @@ -0,0 +1,89 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""JetStream partitioned consumer groups is an implementation of a client-side partitioned consumer group feature for NATS streams.""" + +from jetstreampcg._version import __version__ +from jetstreampcg.common import ( + ConsumerGroupConsumeContext, + ConsumerGroupMsg, + MemberMapping, + compose_key, + generate_partition_filters, +) +from jetstreampcg.elastic import ( + ElasticConsumerGroupConfig, + ElasticConsumerGroupConsumerInstance, + add_members, + create_elastic, + delete_elastic, + delete_member_mappings, + delete_members, + elastic_consume, + elastic_get_partition_filters, + elastic_is_in_membership_and_active, + elastic_member_step_down, + get_elastic_consumer_group_config, + list_elastic_active_members, + list_elastic_consumer_groups, + set_member_mappings, +) +from jetstreampcg.static import ( + StaticConsumerGroupConfig, + StaticConsumerGroupConsumerInstance, + create_static, + delete_static, + get_static_consumer_group_config, + list_static_active_members, + list_static_consumer_groups, + static_consume, + static_member_step_down, + validate_static_config, +) + +__all__ = [ + # Common + "ConsumerGroupConsumeContext", + "ConsumerGroupMsg", + # Elastic + "ElasticConsumerGroupConfig", + "ElasticConsumerGroupConsumerInstance", + "MemberMapping", + # Static + "StaticConsumerGroupConfig", + "StaticConsumerGroupConsumerInstance", + "__version__", + "add_members", + "compose_key", + "create_elastic", + "create_static", + "delete_elastic", + "delete_member_mappings", + "delete_members", + "delete_static", + "elastic_consume", + "elastic_get_partition_filters", + "elastic_is_in_membership_and_active", + "elastic_member_step_down", + "generate_partition_filters", + "get_elastic_consumer_group_config", + "get_static_consumer_group_config", + "list_elastic_active_members", + "list_elastic_consumer_groups", + "list_static_active_members", + "list_static_consumer_groups", + "set_member_mappings", + "static_consume", + "static_member_step_down", + "validate_static_config", +] diff --git a/jetstreampcg/src/jetstreampcg/_graceful_exit.py b/jetstreampcg/src/jetstreampcg/_graceful_exit.py new file mode 100644 index 0000000..ed9e5f6 --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/_graceful_exit.py @@ -0,0 +1,127 @@ +"""Minimal example of graceful exit implementation for reference in development of the consumers""" + +import asyncio +import logging +import random + + +async def watch_done(future: asyncio.Future[None]) -> None: + """A task that will complete when the done future result is set""" + logger = logging.getLogger("watch_done") + + try: + logger.info("awaiting future...") + await future + logger.info("future set") + except asyncio.CancelledError: + logger.info("graceful shutdown complete") + raise + + +async def watch_cancel(event: asyncio.Event) -> None: + """A task that will complete when the cancel event is set""" + logger = logging.getLogger("watch_cancel") + + try: + logger.info("awaiting event...") + _ = await event.wait() + logger.info("event set") + except asyncio.CancelledError: + logger.info("graceful shutdown complete") + raise + + +async def main_loop() -> None: + """Where the real work actually happens""" + logger = logging.getLogger("main_loop") + + try: + while True: + logger.info("doing work") + _ = await asyncio.sleep(1) + except asyncio.CancelledError: + logger.info("received cancellation signal...") + await asyncio.sleep(random.uniform(2, 4)) # noqa: S311 + logger.info("graceful shutdown complete") + raise + + +async def supervisor(done: asyncio.Future[None], cancel: asyncio.Event) -> None: + """Randomly sets done or cancel""" + logger = logging.getLogger("supervisor") + + try: + while True: + logger.info("supervising") + _ = await asyncio.sleep(1) + x = random.random() # noqa: S311 + if x < 0.8: + logger.info("continue") + elif 0.8 <= x < 0.9: + if not done.done(): + done.set_result(None) + logger.info("set done result") + elif 0.9 <= x < 1.0: + if not cancel.is_set(): + cancel.set() + logger.info("set cancel") + + except asyncio.CancelledError: + logger.info("received cancellation signal...") + await asyncio.sleep(1) + logger.info("graceful shutdown complete") + raise + + +async def main() -> None: + """Creates all concurrent tasks""" + logger = logging.getLogger("main") + + done_future = asyncio.Future[None]() + cancel_event = asyncio.Event() + + tasks = [ + asyncio.create_task(watch_done(done_future), name="watch_done"), + asyncio.create_task(watch_cancel(cancel_event), name="watch_cancel"), + asyncio.create_task(supervisor(done_future, cancel_event), name="supervisor"), + asyncio.create_task(main_loop(), name="main_loop"), + ] + logger.info("tasks created") + try: + logger.info("waiting for first task to complete") + _, pending = await asyncio.wait( + tasks, + return_when=asyncio.FIRST_COMPLETED, + ) + + if pending: + try: + _ = await asyncio.wait_for(asyncio.gather(*pending), timeout=3) + except asyncio.TimeoutError: + logger.warning("graceful shutdown timeout - forcing exit") + + except asyncio.CancelledError: + logger.info("graceful shutdown requested, waiting...") + _, pending = await asyncio.wait(tasks, timeout=3) + if pending: + for p in pending: + _ = p.cancel() + logger.warning("%s didn't complete graceful shutdown", p.get_name()) + + +def entrypoint() -> None: + """Program entrypoint""" + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s::%(message)s" + ) + logger = logging.getLogger("entrypoint") + + logger.info("launching main") + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("goodbye.") + + +if __name__ == "__main__": + entrypoint() diff --git a/jetstreampcg/src/jetstreampcg/_version.py b/jetstreampcg/src/jetstreampcg/_version.py new file mode 100644 index 0000000..0378c26 --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/_version.py @@ -0,0 +1,5 @@ +from importlib.metadata import version + +__version__ = version("jetstreampcg") + +__all__ = ["__version__"] diff --git a/jetstreampcg/src/jetstreampcg/common.py b/jetstreampcg/src/jetstreampcg/common.py new file mode 100644 index 0000000..8e86f63 --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/common.py @@ -0,0 +1,167 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from nats.aio.msg import Msg +from typing_extensions import Self + +if TYPE_CHECKING: + import asyncio + + from nats.aio.client import Client as NATS + +# Contains the things that are common to both types of consumer groups +# TODO: the failover times and elasticity reaction times could be made more 'real time' by lowering those values. And could maybe be exposed as tunable by the user by making them all derive from the ack wait value set by the user in the consumer config it passes in when joining the consumer group +# At this point however those values are hard-coded to values that seemed to be reasonable in terms of not incurring too much overhead. This is expected to be revisited later according to community feedback. +PULL_TIMEOUT = 3.0 +ACK_WAIT = 2 * PULL_TIMEOUT +CONSUMER_IDLE_TIMEOUT = 2 * PULL_TIMEOUT + + +@dataclass +class MemberMapping: + """Mapping of a member to its assigned partitions.""" + + member: str + partitions: list[int] + + +class ConsumerGroupConsumeContext(ABC): + @abstractmethod + def stop(self) -> None: ... + @abstractmethod + def done(self) -> asyncio.Future[Exception | None]: ... + + +def compose_key(stream_name: str, consumer_group_name: str) -> str: + """Compose the consumer group's config key name.""" + return f"{stream_name}.{consumer_group_name}" + + +def generate_partition_filters( + members: list[str], + max_members: int, + member_mappings: list[MemberMapping], + member_name: str, +) -> list[str]: + """Generate the partition filters for a particular member of a consumer group. + + Args: + members: List of member names + max_members: Maximum number of members allowed + member_mappings: Explicit member to partition mappings + member_name: Name of the member to generate filters for + + Returns: + List of partition filter strings (e.g., ["0.>", "1.>"]) + """ + if members: + members = _deduplicate_string_list(members) + members.sort() + + if len(members) > max_members: + members = members[:max_members] + + # Distribute the partitions amongst the members trying to minimize the number of + # partitions getting re-distributed to another member as the number of members + # increases/decreases + num_members = len(members) + + if num_members > 0: + # Rounded number of partitions per member + num_per = max_members // num_members + my_filters: list[str] = [] + + for i in range(max_members): + member_index = i // num_per + + if i < (num_members * num_per): + if members[member_index % num_members] == member_name: + my_filters.append(f"{i}.>") + else: + # Remainder if the number of partitions is not a multiple of members + if ( + members[(i - (num_members * num_per)) % num_members] + == member_name + ): + my_filters.append(f"{i}.>") + + return my_filters + return [] + elif member_mappings: + return [ + f"{partition}.>" + for mapping in member_mappings + if mapping.member == member_name + for partition in mapping.partitions + ] + return [] + + +class ConsumerGroupMsg(Msg): + """JetStream message wrapper to strip the partition number from the subject.""" + + def __init__( + self, + _client: NATS, + subject: str = "", + reply: str = "", + data: bytes = b"", + headers: dict[str, str] | None = None, + _metadata: Msg.Metadata | None = None, + _ackd: bool = False, # noqa: FBT001,FBT002 + _sid: int | None = None, + ) -> None: + super().__init__( + _client=_client, + subject=subject, + reply=reply, + data=data, + headers=headers, + _metadata=_metadata, + _ackd=_ackd, + _sid=_sid, + ) + self._original_subject: str = self.subject + dot_index = self._original_subject.find(".") + if dot_index != -1: + self.subject: str = self._original_subject[dot_index + 1 :] + + @classmethod + def from_msg(cls, msg: Msg) -> Self: + return cls( + _client=msg._client, + subject=msg.subject, + reply=msg.reply, + data=msg.data, + headers=msg.headers, + _metadata=msg._metadata, + _ackd=msg._ackd, + _sid=msg._sid, + ) + + +def _deduplicate_string_list(items: list[str]) -> list[str]: + """Remove duplicates from a string list while preserving order.""" + seen: set[str] = set() + result: list[str] = [] + for item in items: + if item not in seen: + seen.add(item) + result.append(item) + return result diff --git a/jetstreampcg/src/jetstreampcg/elastic.py b/jetstreampcg/src/jetstreampcg/elastic.py new file mode 100644 index 0000000..aef5e15 --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/elastic.py @@ -0,0 +1,1103 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +import logging +import random +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from nats.js.api import ( + ConsumerConfig, + ConsumerInfo, + DiscardPolicy, + KeyValueConfig, + PriorityPolicy, + RetentionPolicy, + StorageType, + StreamConfig, + StreamSource, + SubjectTransform, +) +from nats.js.errors import ( + APIError, + BucketNotFoundError, + KeyNotFoundError, + KeyValueError, + NotFoundError, +) +from typing_extensions import override + +from .common import ( + ACK_WAIT, + CONSUMER_IDLE_TIMEOUT, + PULL_TIMEOUT, + ConsumerGroupConsumeContext, + ConsumerGroupMsg, + MemberMapping, + compose_key, + generate_partition_filters, +) + +if TYPE_CHECKING: + from nats.aio.msg import Msg + from nats.js import JetStreamContext + from nats.js.api import StreamInfo + from nats.js.kv import KeyValue + +logger = logging.getLogger(__name__) + +KV_ELASTIC_BUCKET_NAME = "elastic-consumer-groups" + + +class ElasticConsumerGroupConsumerInstance(ConsumerGroupConsumeContext): + """Instance for consuming messages from an elastic consumer group.""" + + stream_name: str + consumer_group_name: str + member_name: str + config: ElasticConsumerGroupConfig + message_handler: Callable[[Msg], Awaitable[None]] + consumer_user_config: ConsumerConfig + consumer: ConsumerInfo | None + current_pinned_id: str + js: JetStreamContext + kv: KeyValue + subscription: JetStreamContext.PullSubscription | None + key_watcher: KeyValue.KeyWatcher | None + _cancel_event: asyncio.Event + _done_future: asyncio.Future[Exception | None] + _consume_task: asyncio.Task[None] | None + _instance_task: asyncio.Task[None] | None + + def __init__( + self, + stream_name: str, + consumer_group_name: str, + member_name: str, + config: ElasticConsumerGroupConfig, + message_handler: Callable[[Msg], Awaitable[None]], + consumer_config: ConsumerConfig, + js: JetStreamContext, + kv: KeyValue, + ): + self.stream_name = stream_name + self.consumer_group_name = consumer_group_name + self.member_name = member_name + self.config = config + self.message_handler = message_handler + self.consumer_user_config = consumer_config + self.js = js + self.kv = kv + + self._cancel_event = asyncio.Event() + self.key_watcher = None + self._done_future = asyncio.Future() + self.consumer = None + self.current_pinned_id = "" + self.subscription = None + self._consume_task = None + self._instance_task = None + + @override + def stop(self) -> None: + """Stop the consumer group instance.""" + self._cancel_event.set() + + @override + def done(self) -> asyncio.Future[Exception | None]: + """Future that completes when consumption is done.""" + return self._done_future + + async def start(self) -> None: + """Start the consumer group instance.""" + # Watch for config changes + self.key_watcher = await self.kv.watch( # pyright: ignore[reportUnknownMemberType] + compose_key(self.stream_name, self.consumer_group_name) + ) + + # Join the consumer group if member is in membership + if self.config.is_in_membership(self.member_name): + await self._join_member_consumer() + + # Start the instance routine + self._instance_task = asyncio.create_task(self._instance_routine()) + + async def _instance_routine(self) -> None: + """Control routine that watches for changes in the consumer group config.""" + try: + # Create a task to watch for key updates + async def watch_updates(): + if self.key_watcher: + async for update_msg in self.key_watcher: + yield update_msg + + update_iterator = watch_updates() + + while not self._cancel_event.is_set(): + tasks = [] + try: + # Create tasks for different events + update_task = asyncio.create_task(anext(update_iterator)) + timeout_task = asyncio.create_task( + asyncio.sleep(CONSUMER_IDLE_TIMEOUT + 1.0) + ) + cancel_task = asyncio.create_task(self._cancel_event.wait()) + + tasks = [update_task, timeout_task, cancel_task] + + # Wait for first to complete + done, pending = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED + ) + + # Cancel pending tasks + for task in pending: + _ = task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Process completed task + for task in done: + if task is cancel_task: + # Context was cancelled + await self._stop_consuming() + self._done_future.set_result(None) + return + + elif task is update_task: + try: + update_msg = update_task.result() + except StopAsyncIteration: + # Key watcher ended + await self._stop_consuming() + self._done_future.set_exception( + ValueError( + "the elastic consumer group config watcher has been closed, stopping" + ) + ) + return + + # Check if config was deleted + if update_msg and update_msg.operation == "DELETE": + await self._stop_consuming() + self._done_future.set_result(None) + return + + # Parse and validate new config + try: + if not update_msg or update_msg.value is None: + continue + new_config = ElasticConsumerGroupConfig.from_dict( + json.loads(update_msg.value) # pyright: ignore[reportAny] + ) + _validate_config(new_config) + except (json.JSONDecodeError, ValueError) as e: + await self._stop_consuming() + self._done_future.set_exception(e) + return + + # Check if critical config changed + if ( + new_config.max_members != self.config.max_members + or new_config.filter != self.config.filter + or new_config.max_buffered_msgs + != self.config.max_buffered_msgs + or new_config.max_buffered_bytes + != self.config.max_buffered_bytes + or new_config.partitioning_wildcards + != self.config.partitioning_wildcards + ): + await self._stop_consuming() + self._done_future.set_exception( + ValueError( + "elastic consumer group config watcher received a bad change in the configuration" + ) + ) + return + + # Check if membership changed + if ( + self.consumer is not None + and new_config.members == self.config.members + and len(new_config.member_mappings) + == len(self.config.member_mappings) + and all( + nm.member == om.member + and nm.partitions == om.partitions + for nm, om in zip( + new_config.member_mappings, + self.config.member_mappings, + strict=False, + ) + ) + ): + # No change needed + continue + + # Update config and process membership change + self.config.members = new_config.members + self.config.member_mappings = new_config.member_mappings + await self._process_membership_change() + + elif task is timeout_task: + # Self-correction: try to join if not currently joined but should be + if self.consumer is None and self.config.is_in_membership( + self.member_name + ): + await self._join_member_consumer() + + except Exception as e: + # Ensure cleanup on any error + for t in tasks: + if not t.done(): + _ = t.cancel() + raise e + + except Exception as e: # noqa: BLE001 + await self._stop_consuming() + if not self._done_future.done(): + self._done_future.set_exception(e) + + async def _consumer_callback(self, msg: Msg) -> None: + """Shim callback to strip partition number from subject before passing to user.""" + # Check pinned-id header + pid = msg.headers.get("Nats-Pin-Id") if msg.headers else None + if not pid: + logger.warning("Received a message without a pinned-id header") + else: + if not self.current_pinned_id: + self.current_pinned_id = pid + elif self.current_pinned_id != pid: + # Pinned member changed + self.current_pinned_id = pid + + # Wrap message and call user handler + wrapped_msg = ConsumerGroupMsg.from_msg(msg) + await self.message_handler(wrapped_msg) + + async def _join_member_consumer(self) -> None: + """Attempt to create the member's consumer and start consuming if successful.""" + filters = generate_partition_filters( + self.config.members, + self.config.max_members, + self.config.member_mappings, + self.member_name, + ) + + # if we are no longer in the membership list, nothing to do + if not filters: + return + + config = self.consumer_user_config + config.durable_name = None # Not durable for elastic + config.name = self.member_name + config.filter_subjects = filters + + # Configure priority groups for pinned consumer behavior + config.priority_groups = [self.member_name] + config.priority_policy = PriorityPolicy.PINNED + config.priority_timeout = config.ack_wait + + # Try to create consumer + consumer_stream_name = _compose_cgs_name( + self.stream_name, self.consumer_group_name + ) + try: + self.consumer = await self.js.add_consumer(consumer_stream_name, config) # pyright: ignore[reportUnknownMemberType] + except APIError as e: + # Check if consumer already exists + if "consumer already exists" in str(e).lower(): + # Try to delete and recreate + try: + _ = await self.js.delete_consumer( + consumer_stream_name, self.member_name + ) + self.consumer = await self.js.add_consumer( # pyright: ignore[reportUnknownMemberType] + consumer_stream_name, config + ) + except Exception: # noqa: BLE001 + # Will retry after timeout in instance routine + logger.debug( + "Failed to delete and recreate consumer, will retry later" + ) + return + else: + # Some other API error - will retry after timeout + logger.debug("API error creating consumer, will retry later: %s", e) + return + except Exception as e: # noqa: BLE001 + # Not logging at info level because errors can happen during normal operation + # (e.g., "filtered consumer not unique on workqueue stream" can occur when + # members aren't perfectly synchronized processing membership changes) + logger.debug("Error creating consumer, will retry later: %s", e) + return + + await self._start_consuming() + + async def _start_consuming(self) -> None: + """Start actively consuming messages from the consumer.""" + if not self.consumer: + return + + consumer_stream_name = _compose_cgs_name( + self.stream_name, self.consumer_group_name + ) + + # Create pull subscription with priority_group + self.subscription = await self.js.pull_subscribe( + "", + durable=self.member_name, + stream=consumer_stream_name, + priority_group=self.member_name, + ) + + # Start processing messages + async def process_messages(): + while not self._cancel_event.is_set(): + try: + if self.subscription is None: + break + msgs = await self.subscription.fetch(batch=1, timeout=PULL_TIMEOUT) + for msg in msgs: + await self._consumer_callback(msg) + except asyncio.TimeoutError: + continue + except Exception as e: # noqa: BLE001 + logger.error("Error processing messages: %s", e) + # Continue trying to fetch messages, don't break on transient errors + continue + + self._consume_task = asyncio.create_task(process_messages()) + + async def _stop_consuming(self) -> None: + """Stop consuming messages from the consumer.""" + if self._consume_task: + _ = self._consume_task.cancel() + try: + await self._consume_task + except asyncio.CancelledError: + pass + self._consume_task = None + + if self.subscription: + try: + await self.subscription.unsubscribe() + except Exception as e: # noqa: BLE001 + # Ignore errors during cleanup - subscription may already be closed + logger.debug("Error unsubscribing during stop: %s", e) + self.subscription = None + + self.consumer = None + + async def _process_membership_change(self) -> None: + """Process membership changes.""" + # Get current consumer info to check if pinned + is_pinned = False + + if self.consumer: + consumer_stream_name = _compose_cgs_name( + self.stream_name, self.consumer_group_name + ) + try: + consumer_info = await self.js.consumer_info( + consumer_stream_name, self.member_name + ) + # Check if we're the pinned consumer by examining priority_groups + if ( + hasattr(consumer_info, "priority_groups") + and consumer_info.priority_groups + ): + for pg in consumer_info.priority_groups: + if ( + hasattr(pg, "group") + and pg.group == self.member_name + and hasattr(pg, "pinned_client_id") + and pg.pinned_client_id == self.current_pinned_id + ): + is_pinned = True + break + except Exception as e: # noqa: BLE001 + # Ignore errors getting consumer info - consumer may not exist yet + logger.debug("Error getting consumer info: %s", e) + + await self._stop_consuming() + + # Only the pinned member should delete the consumer + if is_pinned: + consumer_stream_name = _compose_cgs_name( + self.stream_name, self.consumer_group_name + ) + try: + _ = await self.js.delete_consumer( + consumer_stream_name, self.member_name + ) + except (NotFoundError, asyncio.TimeoutError): + pass + else: + # Backoff to let pinned member handle it first + await asyncio.sleep(random.randint(400, 500) / 1000.0) # noqa: S311 + + await self._join_member_consumer() + + +@dataclass +class ElasticConsumerGroupConfig: + """Configuration for an elastic consumer group.""" + + max_members: int + filter: str + partitioning_wildcards: list[int] + max_buffered_msgs: int | None = None + max_buffered_bytes: int | None = None + members: list[str] = field(default_factory=list) + member_mappings: list[MemberMapping] = field(default_factory=list) + + def is_in_membership(self, name: str) -> bool: + """Check if the member is in the current membership.""" + # Check member_mappings first + for mapping in self.member_mappings: + if mapping.member == name: + return True + # Then check members list + return name in self.members + + def to_dict(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + """Convert to dictionary for JSON serialization.""" + result: dict[str, Any] = { # pyright: ignore[reportExplicitAny] + "max_members": self.max_members, + "filter": self.filter, + "partitioning_wildcards": self.partitioning_wildcards, + } + if self.max_buffered_msgs is not None: + result["max_buffered_msgs"] = self.max_buffered_msgs + if self.max_buffered_bytes is not None: + result["max_buffered_bytes"] = self.max_buffered_bytes + if self.members: + result["members"] = self.members + if self.member_mappings: + result["member_mappings"] = [ + {"member": m.member, "partitions": m.partitions} + for m in self.member_mappings + ] + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ElasticConsumerGroupConfig: # pyright: ignore[reportExplicitAny] + """Create from dictionary (JSON deserialization).""" + member_mappings = [] + if "member_mappings" in data: + member_mappings = [ + MemberMapping(member=m["member"], partitions=m["partitions"]) # pyright: ignore[reportAny] + for m in data["member_mappings"] # pyright: ignore[reportAny] + ] + return cls( + max_members=data["max_members"], # pyright: ignore[reportAny] + filter=data["filter"], # pyright: ignore[reportAny] + partitioning_wildcards=data["partitioning_wildcards"], # pyright: ignore[reportAny] + max_buffered_msgs=data.get("max_buffered_msgs"), + max_buffered_bytes=data.get("max_buffered_bytes"), + members=data.get("members", []), # pyright: ignore[reportAny] + member_mappings=member_mappings, + ) + + +async def get_elastic_consumer_group_config( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> ElasticConsumerGroupConfig: + """Get the elastic consumer group's config from the KV bucket.""" + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + return await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + +async def elastic_consume( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_name: str, + message_handler: Callable[[Msg], Awaitable[None]], + config: ConsumerConfig, +) -> ConsumerGroupConsumeContext: + """Start consuming messages from an elastic consumer group.""" + if config.ack_policy != "explicit": + msg = "the ack policy when consuming from elastic consumer groups must be explicit" + raise ValueError(msg) + + # Ensure minimum timeouts + if config.inactive_threshold is None or config.inactive_threshold == 0: + config.inactive_threshold = CONSUMER_IDLE_TIMEOUT + + if config.ack_wait is None or config.ack_wait < ACK_WAIT: + config.ack_wait = ACK_WAIT + + # Check stream exists + try: + _ = await js.stream_info(_compose_cgs_name(stream_name, consumer_group_name)) + except NotFoundError as e: + msg = f"the elastic consumer group's stream does not exist: {e}" + raise ValueError(msg) from e + + # Get KV bucket + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + try: + consumer_group_config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + except Exception as e: + msg = f"can not get the current elastic consumer group's config: {e}" + raise ValueError(msg) from e + + # Create instance + instance = ElasticConsumerGroupConsumerInstance( + stream_name=stream_name, + consumer_group_name=consumer_group_name, + member_name=member_name, + config=consumer_group_config, + message_handler=message_handler, + consumer_config=config, + js=js, + kv=kv, + ) + + # Start the instance + await instance.start() + + return instance + + +async def create_elastic( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + max_num_members: int, + filter: str, # noqa: A002 + partitioning_wildcards: list[int], + max_buffered_messages: int | None = None, + max_buffered_bytes: int | None = None, +) -> ElasticConsumerGroupConfig: + """Create an elastic consumer group and its sourcing work queue stream.""" + config = ElasticConsumerGroupConfig( + max_members=max_num_members, + filter=filter, + partitioning_wildcards=partitioning_wildcards, + max_buffered_msgs=max_buffered_messages, + max_buffered_bytes=max_buffered_bytes, + ) + + # Validate config + _validate_config(config) + + # Get partitioning transform destination + filter_dest = _get_partitioning_transform_dest(config) + + # Get stream info for replicas + try: + stream_info: StreamInfo = await js.stream_info(stream_name) + except NotFoundError as e: + msg = f"stream {stream_name} not found" + raise ValueError(msg) from e + + replicas = stream_info.config.num_replicas or 1 + storage = stream_info.config.storage or StorageType.FILE + + # Get or create KV bucket + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except BucketNotFoundError: + kv = await js.create_key_value( # pyright: ignore[reportUnknownMemberType] + KeyValueConfig( + bucket=KV_ELASTIC_BUCKET_NAME, + replicas=replicas, + storage=StorageType.FILE, + ) + ) + + # Check if config already exists + key = compose_key(stream_name, consumer_group_name) + try: + entry = await kv.get(key) + # Config exists, verify it matches + if entry.value is None: + msg = "elastic consumer group config has no value" + raise ValueError(msg) + existing_config = ElasticConsumerGroupConfig.from_dict(json.loads(entry.value)) # pyright: ignore[reportAny] + + if ( + existing_config.max_members != max_num_members + or existing_config.filter != filter + or existing_config.max_buffered_msgs != max_buffered_messages + or existing_config.max_buffered_bytes != max_buffered_bytes + or existing_config.partitioning_wildcards != partitioning_wildcards + ): + msg = "the existing elastic consumer group config can not be updated" + raise ValueError(msg) + + consumer_group_config = existing_config + except KeyNotFoundError: + # Create new entry + consumer_group_config = config + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(key, payload) + + # Create the consumer group's stream + stream_config = StreamConfig( + name=_compose_cgs_name(stream_name, consumer_group_name), + retention=RetentionPolicy.WORK_QUEUE, + num_replicas=replicas, + storage=storage, + max_msgs=max_buffered_messages, + max_bytes=max_buffered_bytes, + discard=DiscardPolicy.NEW, + sources=[ + StreamSource( + name=stream_name, + opt_start_seq=0, + filter_subject="", + subject_transforms=[SubjectTransform(src=filter, dest=filter_dest)], + ) + ], + allow_direct=True, + ) + + try: + _ = await js.add_stream(stream_config) # pyright: ignore[reportUnknownMemberType] + except Exception as e: + msg = f"can't create the elastic consumer group's stream: {e}" + raise ValueError(msg) from e + + return consumer_group_config + + +async def delete_elastic( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> None: + """Delete an elastic consumer group.""" + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError: + return + + # Delete config from KV + try: + _ = await kv.delete(compose_key(stream_name, consumer_group_name)) + except KeyNotFoundError: + pass + + # Delete the consumer group's stream + try: + _ = await js.delete_stream(_compose_cgs_name(stream_name, consumer_group_name)) + except NotFoundError: + pass + + +async def list_elastic_consumer_groups( + js: JetStreamContext, stream_name: str +) -> list[str]: + """List the elastic consumer groups for a given stream.""" + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"error getting elastic consumer group KV bucket: {e}" + raise ValueError(msg) from e + + consumer_group_names: list[str] = [] + try: + keys = await kv.keys() # pyright: ignore[reportUnknownMemberType] + for key in keys: + parts = key.split(".") + if len(parts) >= 2 and parts[0] == stream_name: + consumer_group_names.append(parts[1]) + except Exception as e: + msg = f"error listing keys in elastic consumer groups' bucket: {e}" + raise ValueError(msg) from e + + return consumer_group_names + + +async def add_members( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_names_to_add: list[str], +) -> list[str]: + """Add members to an elastic consumer group.""" + if not stream_name or not consumer_group_name or not member_names_to_add: + msg = "invalid stream name or elastic consumer group name or no member names" + raise ValueError(msg) + + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + if config.member_mappings: + msg = "can't add members to an elastic consumer group that uses member mappings" + raise ValueError(msg) + + # Add new members (deduplicated) + existing_members = set(config.members) + for member_name in member_names_to_add: + if member_name: + existing_members.add(member_name) + + config.members = list(existing_members) + + # Update config + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(compose_key(stream_name, consumer_group_name), payload) + + return config.members + + +async def delete_members( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_names_to_drop: list[str], +) -> list[str]: + """Drop members from an elastic consumer group.""" + if not stream_name or not consumer_group_name or not member_names_to_drop: + msg = "invalid stream name or elastic consumer group name or no member names" + raise ValueError(msg) + + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + if config.member_mappings: + msg = "can't drop members from an elastic consumer group that uses member mappings" + raise ValueError(msg) + + # Remove members + dropping_members = set(member_names_to_drop) + config.members = [m for m in config.members if m not in dropping_members] + + # Update config + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(compose_key(stream_name, consumer_group_name), payload) + + return config.members + + +async def set_member_mappings( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_mappings: list[MemberMapping], +) -> None: + """Set the custom member mappings for an elastic consumer group.""" + if not stream_name or not consumer_group_name or not member_mappings: + msg = "invalid stream name or elastic consumer group name or member mappings" + raise ValueError(msg) + + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + # Clear members and set mappings + config.members = [] + config.member_mappings = member_mappings + + # Validate updated config + _validate_config(config) + + # Update config + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(compose_key(stream_name, consumer_group_name), payload) + + +async def delete_member_mappings( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> None: + """Delete the custom member mappings for an elastic consumer group.""" + if not stream_name or not consumer_group_name: + msg = "invalid stream name or elastic consumer group name" + raise ValueError(msg) + + try: + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the elastic consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + # Clear mappings + config.member_mappings = [] + + # Update config + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(compose_key(stream_name, consumer_group_name), payload) + + +async def list_elastic_active_members( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> list[str]: + """List the active members of an elastic consumer group.""" + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + if not config.members and not config.member_mappings: + return [] + + # Get consumers from the consumer group's stream + consumer_stream_name = _compose_cgs_name(stream_name, consumer_group_name) + try: + consumers_info = await js.consumers_info(consumer_stream_name) + except NotFoundError: + return [] + + active_members: list[str] = [] + + for consumer_info in consumers_info: + # Check members list + if config.members: + for member in config.members: + if consumer_info.name == member: + active_members.append(member) + break + # Check member mappings + elif config.member_mappings: + for mapping in config.member_mappings: + if consumer_info.name == mapping.member: + active_members.append(mapping.member) + break + + return active_members + + +async def elastic_is_in_membership_and_active( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_name: str, +) -> tuple[bool, bool]: + """Check if a member is included in the elastic consumer group and is active.""" + kv = await js.key_value(KV_ELASTIC_BUCKET_NAME) + + config = await _get_elastic_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + in_membership = config.is_in_membership(member_name) + + # Check if active + consumer_stream_name = _compose_cgs_name(stream_name, consumer_group_name) + try: + consumers_info = await js.consumers_info(consumer_stream_name) + except NotFoundError: + return in_membership, False + + is_active = False + for consumer_info in consumers_info: + if consumer_info.name == member_name: + is_active = True + break + + return in_membership, is_active + + +async def elastic_member_step_down( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_name: str, +) -> None: + """Force the current active (pinned) application instance for a member to step down.""" + consumer_stream_name = _compose_cgs_name(stream_name, consumer_group_name) + + # Use the unpin_consumer API to force the current pinned instance to step down + try: + await js.unpin_consumer(consumer_stream_name, member_name, member_name) + except NotFoundError: + pass + + +def elastic_get_partition_filters( + config: ElasticConsumerGroupConfig, member_name: str +) -> list[str]: + """Get the list of partition filters for the given member.""" + return generate_partition_filters( + config.members, config.max_members, config.member_mappings, member_name + ) + + +# Helper functions + + +def _validate_config(config: ElasticConsumerGroupConfig) -> None: + """Validate an elastic consumer group configuration.""" + # Validate max_members + if config.max_members < 1: + msg = "the max number of members must be >= 1" + raise ValueError(msg) + + # Validate filter and partitioning wildcards + filter_tokens = config.filter.split(".") + num_wildcards = sum(1 for token in filter_tokens if token == "*") # noqa: S105 + + if num_wildcards < 1: + msg = "filter must contain at least one * wildcard" + raise ValueError(msg) + + if not (1 <= len(config.partitioning_wildcards) <= num_wildcards): + msg = "the number of partitioning wildcards must be between 1 and the total number of * wildcards in the filter" + raise ValueError(msg) + + seen_wildcards: set[int] = set() + for wildcard in config.partitioning_wildcards: + if wildcard in seen_wildcards: + msg = "partitioning wildcard indexes must be unique" + raise ValueError(msg) + seen_wildcards.add(wildcard) + + if not (1 <= wildcard <= num_wildcards): + msg = "partitioning wildcard indexes must be between 1 and the number of * wildcards in the filter" + raise ValueError(msg) + + # Can't have both members and member_mappings + if config.members and config.member_mappings: + msg = "either members or member mappings must be provided, not both" + raise ValueError(msg) + + # Validate member_mappings if provided + if config.member_mappings: + if not (1 <= len(config.member_mappings) <= config.max_members): + msg = "the number of member mappings must be between 1 and the max number of members" + raise ValueError(msg) + + seen_members: set[str] = set() + seen_partitions: set[int] = set() + + for mapping in config.member_mappings: + # Check unique members + if mapping.member in seen_members: + msg = "member names must be unique" + raise ValueError(msg) + seen_members.add(mapping.member) + + # Check unique partitions + for partition in mapping.partitions: + if partition in seen_partitions: + msg = "partition numbers must be used only once" + raise ValueError(msg) + seen_partitions.add(partition) + + # Check partition range + if not (0 <= partition < config.max_members): + msg = "partition numbers must be between 0 and one less than the max number of members" + raise ValueError(msg) + + # Check all partitions are covered + if len(seen_partitions) != config.max_members: + msg = "the number of unique partition numbers must be equal to the max number of members" + raise ValueError(msg) + + +def _get_partitioning_transform_dest(config: ElasticConsumerGroupConfig) -> str: + """Get the partitioning transform destination string.""" + wildcard_list = ",".join(str(wc) for wc in config.partitioning_wildcards) + cw_index = 1 + filter_tokens = config.filter.split(".") + + for i, token in enumerate(filter_tokens): + if token == "*": # noqa: S105 + filter_tokens[i] = "{{Wildcard(%d)}}" % cw_index # noqa: UP031 # intentional printf style for readability + cw_index += 1 + + dest_from_filter = ".".join(filter_tokens) + # Use printf-style for the partition part too + return "{{Partition(%d,%s)}}.%s" % ( # noqa: UP031 # intentional printf style for readability + config.max_members, + wildcard_list, + dest_from_filter, + ) + + +async def _get_elastic_consumer_group_config( + kv: KeyValue, stream_name: str, consumer_group_name: str +) -> ElasticConsumerGroupConfig: + """Internal function to get elastic consumer group config.""" + if not stream_name or not consumer_group_name: + msg = "invalid stream name or elastic consumer group name" + raise ValueError(msg) + + try: + entry = await kv.get(compose_key(stream_name, consumer_group_name)) + except KeyNotFoundError as e: + msg = "error getting the elastic consumer group's config: not found" + raise ValueError(msg) from e + + if entry.value is None: + msg = "elastic consumer group config has no value" + raise ValueError(msg) + + try: + data = json.loads(entry.value) # pyright: ignore[reportAny] + config = ElasticConsumerGroupConfig.from_dict(data) # pyright: ignore[reportAny] + except (json.JSONDecodeError, KeyError) as e: + msg = f"invalid JSON value for the elastic consumer group's config: {e}" + raise ValueError(msg) from e + + _validate_config(config) + return config + + +def _compose_cgs_name(stream_name: str, consumer_group_name: str) -> str: + """Compose the Consumer Group Stream Name.""" + return f"{stream_name}-{consumer_group_name}" diff --git a/jetstreampcg/src/jetstreampcg/py.typed b/jetstreampcg/src/jetstreampcg/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/jetstreampcg/src/jetstreampcg/static.py b/jetstreampcg/src/jetstreampcg/static.py new file mode 100644 index 0000000..8a4096f --- /dev/null +++ b/jetstreampcg/src/jetstreampcg/static.py @@ -0,0 +1,711 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from nats.js.api import ConsumerConfig, KeyValueConfig, PriorityPolicy, StorageType +from nats.js.errors import ( + BucketNotFoundError, + KeyNotFoundError, + KeyValueError, + NotFoundError, +) +from typing_extensions import override + +from .common import ( + ACK_WAIT, + PULL_TIMEOUT, + ConsumerGroupConsumeContext, + ConsumerGroupMsg, + MemberMapping, + compose_key, + generate_partition_filters, +) + +if TYPE_CHECKING: + from nats.aio.msg import Msg + from nats.js import JetStreamContext + from nats.js.api import StreamInfo + from nats.js.client import JetStreamContext as JSContext + from nats.js.kv import KeyValue + +logger = logging.getLogger(__name__) + +# Constants +KV_STATIC_BUCKET_NAME = "static-consumer-groups" + + +class StaticConsumerGroupConsumerInstance(ConsumerGroupConsumeContext): + """Instance for consuming messages from a static consumer group.""" + + stream_name: str + consumer_group_name: str + member_name: str + config: StaticConsumerGroupConfig + message_handler: Callable[[Msg], Awaitable[None]] + consumer_user_config: ConsumerConfig + consumer_name: str | None + current_pinned_id: str + # consumer_consume_context + js: JetStreamContext + kv: KeyValue + _cancel_event: asyncio.Event + key_watcher: KeyValue.KeyWatcher | None + _done_future: asyncio.Future[Exception | None] + + subscription: JSContext.PullSubscription | None + _consume_task: asyncio.Task[None] | None + _instance_task: asyncio.Task[None] | None + + def __init__( + self, + stream_name: str, + consumer_group_name: str, + member_name: str, + config: StaticConsumerGroupConfig, + message_handler: Callable[[Msg], Awaitable[None]], + consumer_config: ConsumerConfig, + js: JetStreamContext, + kv: KeyValue, + ): + self.stream_name = stream_name + self.consumer_group_name = consumer_group_name + self.member_name = member_name + self.config = config + self.message_handler = message_handler + self.consumer_user_config = consumer_config + self.js = js + self.kv = kv + self._cancel_event = asyncio.Event() + self.key_watcher = None + self._done_future = asyncio.Future() + + self.consumer_name = None + self.current_pinned_id = "" + self.subscription = None + self._consume_task = None + self._instance_task = None + + @override + def stop(self) -> None: + """Stop the consumer group instance.""" + self._cancel_event.set() + + @override + def done(self) -> asyncio.Future[Exception | None]: + """Return a future that completes when consumption is done.""" + return self._done_future + + async def start(self) -> None: + """Start the consumer group instance.""" + # Watch for config changes + self.key_watcher = await self.kv.watch( # pyright: ignore[reportUnknownMemberType] + compose_key(self.stream_name, self.consumer_group_name) + ) + + # Join the consumer group if member is in membership + if self.config.is_in_membership(self.member_name): + await self._join_member_consumer_static() + else: + msg = ( + "the member name is not in the current static consumer group membership" + ) + raise ValueError(msg) + + # Start the instance routine + self._instance_task = asyncio.create_task(self._instance_routine()) + + async def _instance_routine(self) -> None: + """Control routine that watches for changes in the consumer group config.""" + try: + # Create a task to watch for key updates + async def watch_updates(): + if self.key_watcher: + async for update_msg in self.key_watcher: + yield update_msg + + update_iterator = watch_updates() + + while not self._cancel_event.is_set(): + try: + # Use wait_for with a timeout instead of complex task management + try: + update_msg = await asyncio.wait_for( + anext(update_iterator), + timeout=1.0, # Check cancel event every second + ) + except asyncio.TimeoutError: + continue # Check cancel event and continue + + # Check if config was deleted + if update_msg.operation == "DELETE": + await self._stop_and_delete_member_consumer() + self._done_future.set_result(None) + return + + # Parse and validate new config + try: + new_config = StaticConsumerGroupConfig.from_dict( + json.loads(update_msg.value or b"") # pyright: ignore[reportAny] + ) + validate_static_config(new_config) + except (json.JSONDecodeError, ValueError) as e: + await self._stop_and_delete_member_consumer() + self._done_future.set_exception(e) + return + + # Check if config actually changed + if ( + new_config.max_members != self.config.max_members + or new_config.filter != self.config.filter + or new_config.members != self.config.members + or len(new_config.member_mappings) + != len(self.config.member_mappings) + or any( + nm.member != om.member or nm.partitions != om.partitions + for nm, om in zip( + new_config.member_mappings, + self.config.member_mappings, + strict=False, + ) + ) + ): + await self._stop_and_delete_member_consumer() + self._done_future.set_exception( + ValueError( + "static consumer group config watcher received a change in the configuration, terminating" + ) + ) + return + + except asyncio.CancelledError: + await self._stop_consuming() + self._done_future.set_result(None) + return + except StopAsyncIteration: + # Key watcher ended + await self._stop_consuming() + self._done_future.set_result(None) + return + + except Exception as e: # noqa: BLE001 + # Catch all exceptions to ensure cleanup happens + await self._stop_and_delete_member_consumer() + if not self._done_future.done(): + self._done_future.set_exception(e) + + async def _join_member_consumer_static(self) -> None: + """Create the member's consumer and start consuming.""" + filters = generate_partition_filters( + self.config.members, + self.config.max_members, + self.config.member_mappings, + self.member_name, + ) + + if not filters: + return + + # Configure consumer - use ConsumerConfig from nats-py + + # Build consumer config with proper parameters + consumer_config = self.consumer_user_config + consumer_config.durable_name = _compose_static_consumer_name( + self.consumer_group_name, self.member_name + ) + consumer_config.filter_subjects = filters + consumer_config.ack_wait = self.consumer_user_config.ack_wait or ACK_WAIT + + # Configure priority groups for pinned consumer behavior + consumer_config.priority_groups = [self.member_name] + consumer_config.priority_policy = PriorityPolicy.PINNED + consumer_config.priority_timeout = consumer_config.ack_wait + + # Create consumer + _ = await self.js.add_consumer(self.stream_name, consumer_config) # pyright: ignore[reportUnknownMemberType] + + # Store consumer name for later operations + self.consumer_name = _compose_static_consumer_name( + self.consumer_group_name, self.member_name + ) + + # Start consuming + await self._start_consuming() + + async def _start_consuming(self) -> None: + """Start actively consuming messages from the consumer.""" + if not self.consumer_name: + return + + # Create pull subscription for the consumer + # In nats-py, we use pull_subscribe with the consumer name and priority_group + self.subscription = await self.js.pull_subscribe( + "", + durable=self.consumer_name, + stream=self.stream_name, + priority_group=self.member_name, + ) + + # Start processing messages + async def process_messages(): + while not self._cancel_event.is_set(): + try: + if self.subscription is None: + continue + msgs = await self.subscription.fetch(batch=1, timeout=PULL_TIMEOUT) + for msg in msgs: + await self._consumer_callback(msg) + except asyncio.TimeoutError: + continue + + self._consume_task = asyncio.create_task(process_messages()) + + async def _consumer_callback(self, msg: Msg) -> None: + """Callback to process messages, checking pinned-id header.""" + # Check pinned-id header + pid = msg.headers.get("Nats-Pin-Id") if msg.headers else None + if not pid: + logger.warning("Received a message without a pinned-id header") + else: + if not self.current_pinned_id: + self.current_pinned_id = pid + elif self.current_pinned_id != pid: + # Pinned member changed + self.current_pinned_id = pid + + # Wrap message and call user handler + wrapped_msg = ConsumerGroupMsg.from_msg(msg) + await self.message_handler(wrapped_msg) + + async def _stop_consuming(self) -> None: + """Stop consuming messages from the consumer.""" + if hasattr(self, "_consume_task") and self._consume_task: + _ = self._consume_task.cancel() + try: + await self._consume_task + except asyncio.CancelledError: + pass + if hasattr(self, "subscription") and self.subscription: + await self.subscription.unsubscribe() + self.consumer_name = None + + async def _stop_and_delete_member_consumer(self) -> None: + """Stop consuming and delete the durable consumer.""" + await self._stop_consuming() + + try: + _ = await self.js.delete_consumer( + self.stream_name, + _compose_static_consumer_name( + self.consumer_group_name, self.member_name + ), + ) + except NotFoundError: + pass + + +@dataclass +class StaticConsumerGroupConfig: + """Configuration for a static consumer group.""" + + max_members: int + filter: str = "" + members: list[str] = field(default_factory=list) + member_mappings: list[MemberMapping] = field(default_factory=list) + + def is_in_membership(self, name: str) -> bool: + """Check if the member is in the current membership.""" + # Check member_mappings first + for mapping in self.member_mappings: + if mapping.member == name: + return True + # Then check members list + return name in self.members + + def to_dict(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + """Convert to dictionary for JSON serialization.""" + serializable_dict: dict[str, Any] = { # pyright: ignore[reportExplicitAny] + "max_members": self.max_members, + "filter": self.filter, + } + if self.members: + serializable_dict["members"] = self.members + if self.member_mappings: + serializable_dict["member_mappings"] = [ + {"member": m.member, "partitions": m.partitions} + for m in self.member_mappings + ] + return serializable_dict + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> StaticConsumerGroupConfig: # pyright: ignore[reportExplicitAny] + """Create from dictionary (JSON deserialization).""" + member_mappings = [] + if "member_mappings" in data: + member_mappings = [ + MemberMapping(member=m["member"], partitions=m["partitions"]) # pyright: ignore[reportAny] + for m in data["member_mappings"] # pyright: ignore[reportAny] + ] + return cls( + max_members=data["max_members"], # pyright: ignore[reportAny] + filter=data.get("filter", ""), # pyright: ignore[reportAny] + members=data.get("members", []), # pyright: ignore[reportAny] + member_mappings=member_mappings, + ) + + +async def get_static_consumer_group_config( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> StaticConsumerGroupConfig: + """Get the static consumer group's config from the KV bucket.""" + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the static consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + return await _get_static_consumer_group_config(kv, stream_name, consumer_group_name) + + +async def static_consume( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_name: str, + message_handler: Callable[[Msg], Awaitable[None]], + config: ConsumerConfig, +) -> ConsumerGroupConsumeContext: + """Start consuming messages from a static consumer group.""" + # Ensure minimum ack wait + if config.ack_wait is None or config.ack_wait < ACK_WAIT: + config.ack_wait = ACK_WAIT + + # Check stream exists + try: + _ = await js.stream_info(stream_name) + except NotFoundError as e: + msg = f"the static consumer group's stream does not exist: {e}" + raise ValueError(msg) from e + + # Get KV bucket + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the static consumer group KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + # Get current config + try: + consumer_group_config = await _get_static_consumer_group_config( + kv, stream_name, consumer_group_name + ) + except Exception as e: + msg = f"can not get the current static consumer group's config: {e}" + raise ValueError(msg) from e + + # Create instance + instance = StaticConsumerGroupConsumerInstance( + stream_name=stream_name, + consumer_group_name=consumer_group_name, + member_name=member_name, + config=consumer_group_config, + message_handler=message_handler, + consumer_config=config, + js=js, + kv=kv, + ) + + # Start the instance + await instance.start() + + return instance + + +async def create_static( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + max_members: int, + filter: str = "", # noqa: A002 + members: list[str] | None = None, + member_mappings: list[MemberMapping] | None = None, +) -> StaticConsumerGroupConfig: + """Create a static consumer group.""" + if members is None: + members = [] + if member_mappings is None: + member_mappings = [] + + config = StaticConsumerGroupConfig( + max_members=max_members, + filter=filter, + members=members, + member_mappings=member_mappings, + ) + + # Validate config + validate_static_config(config) + + # Get stream info for replicas + stream_info: StreamInfo = await js.stream_info(stream_name) + replicas = stream_info.config.num_replicas or 1 + + # Get or create KV bucket + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except BucketNotFoundError: + kv = await js.create_key_value( # pyright: ignore[reportUnknownMemberType] + KeyValueConfig( + bucket=KV_STATIC_BUCKET_NAME, + replicas=replicas, + storage=StorageType.FILE, + ) + ) + + # Check if config already exists + key = compose_key(stream_name, consumer_group_name) + try: + entry = await kv.get(key) + # Config exists, verify it matches + if entry.value is None: + msg = "static consumer group config has no value" + raise ValueError(msg) + existing_config = StaticConsumerGroupConfig.from_dict(json.loads(entry.value)) # pyright: ignore[reportAny] + + if ( + existing_config.max_members != max_members + or existing_config.filter != filter + or existing_config.members != members + or len(existing_config.member_mappings) != len(member_mappings) + or any( + em.member != m.member or em.partitions != m.partitions + for em, m in zip( + existing_config.member_mappings, member_mappings, strict=False + ) + ) + ): + msg = "the existing static consumer group config doesn't match" + raise ValueError(msg) + + return existing_config + except KeyNotFoundError: + # Create new entry + payload = json.dumps(config.to_dict()).encode() + _ = await kv.put(key, payload) + return config + + +async def delete_static( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> None: + """Delete a static consumer group.""" + + # Get KV bucket + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except KeyValueError: + return + + # Delete config from KV + try: + _ = await kv.delete(compose_key(stream_name, consumer_group_name)) + except KeyNotFoundError: + pass + + # Delete all consumers for this group + consumers_info = await js.consumers_info(stream_name) + + for consumer_info in consumers_info: + if consumer_info.name.startswith(f"{consumer_group_name}-"): + try: + _ = await js.delete_consumer(stream_name, consumer_info.name) + except NotFoundError: + pass + + +async def list_static_consumer_groups( + js: JetStreamContext, stream_name: str +) -> list[str]: + """List the static consumer groups for a given stream.""" + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except KeyValueError as e: + msg = f"the static consumer group's KV bucket doesn't exist: {e}" + raise ValueError(msg) from e + + consumer_group_names: list[str] = [] + try: + keys = await kv.keys() # pyright: ignore[reportUnknownMemberType] + for key in keys: + parts = key.split(".") + if len(parts) >= 2 and parts[0] == stream_name: + consumer_group_names.append(parts[1]) + except Exception as e: + msg = f"error listing keys in static consumer groups' bucket: {e}" + raise ValueError(msg) from e + + return consumer_group_names + + +async def list_static_active_members( + js: JetStreamContext, stream_name: str, consumer_group_name: str +) -> list[str]: + """List the active members of a static consumer group.""" + # Get KV and config + try: + kv = await js.key_value(KV_STATIC_BUCKET_NAME) + except KeyValueError as e: + raise e + + config = await _get_static_consumer_group_config( + kv, stream_name, consumer_group_name + ) + + # Get stream and consumers + consumers_info = await js.consumers_info(stream_name) + + active_members: list[str] = [] + + for consumer_info in consumers_info: + # Check members list + if config.members: + for member in config.members: + consumer_name = _compose_static_consumer_name( + consumer_group_name, member + ) + if ( + consumer_info.name == consumer_name + and consumer_info.num_waiting + and consumer_info.num_waiting > 0 + ): + active_members.append(member) + break + # Check member mappings + elif config.member_mappings: + for mapping in config.member_mappings: + consumer_name = _compose_static_consumer_name( + consumer_group_name, mapping.member + ) + if ( + consumer_info.name == consumer_name + and consumer_info.num_waiting + and consumer_info.num_waiting > 0 + ): + active_members.append(mapping.member) + break + + return active_members + + +async def static_member_step_down( + js: JetStreamContext, + stream_name: str, + consumer_group_name: str, + member_name: str, +) -> None: + """Force the current active (pinned) member to step down.""" + consumer_name = _compose_static_consumer_name(consumer_group_name, member_name) + + # Use the unpin_consumer API to force the current pinned instance to step down + try: + await js.unpin_consumer(stream_name, consumer_name, member_name) + except Exception as e: + logger.error("Error trying to unpin the member's consumer: %s", e) + raise + + +def validate_static_config(config: StaticConsumerGroupConfig) -> None: + """Validate a static consumer group configuration.""" + # Validate max_members + if config.max_members < 1: + msg = "the max number of members must be >= 1" + raise ValueError(msg) + + # Can't have both members and member_mappings + if config.members and config.member_mappings: + msg = "either members or member mappings must be provided, not both" + raise ValueError(msg) + + # Validate member_mappings if provided + if config.member_mappings: + if not (1 <= len(config.member_mappings) <= config.max_members): + msg = "the number of member mappings must be between 1 and the max number of members" + raise ValueError(msg) + + seen_members: set[str] = set() + seen_partitions: set[int] = set() + + for mapping in config.member_mappings: + # Check unique members + if mapping.member in seen_members: + msg = "member names must be unique" + raise ValueError(msg) + seen_members.add(mapping.member) + + # Check unique partitions + for partition in mapping.partitions: + if partition in seen_partitions: + msg = "partition numbers must be used only once" + raise ValueError(msg) + seen_partitions.add(partition) + + # Check partition range + if not (0 <= partition < config.max_members): + msg = "partition numbers must be between 0 and one less than the max number of members" + raise ValueError(msg) + + # Check all partitions are covered + if len(seen_partitions) != config.max_members: + msg = "the number of unique partition numbers must be equal to the max number of members" + raise ValueError(msg) + + +async def _get_static_consumer_group_config( + kv: KeyValue, stream_name: str, consumer_group_name: str +) -> StaticConsumerGroupConfig: + """Internal function to get static consumer group config.""" + if not stream_name or not consumer_group_name: + msg = "invalid stream name or consumer group name" + raise ValueError(msg) + + try: + entry = await kv.get(compose_key(stream_name, consumer_group_name)) + except KeyNotFoundError as e: + msg = "error getting the static consumer group's config: not found" + raise ValueError(msg) from e + + if entry.value is None: + msg = "static consumer group config has no value" + raise ValueError(msg) + + try: + data = json.loads(entry.value) # pyright: ignore[reportAny] + config = StaticConsumerGroupConfig.from_dict(data) # pyright: ignore[reportAny] + except (json.JSONDecodeError, KeyError) as e: + msg = f"invalid JSON value for the static consumer group's config: {e}" + raise ValueError(msg) from e + + validate_static_config(config) + return config + + +def _compose_static_consumer_name(cg_name: str, member: str) -> str: + """Compose the stream's consumer name for the member in the static consumer group.""" + return f"{cg_name}-{member}" diff --git a/jetstreampcg/test/conftest.py b/jetstreampcg/test/conftest.py new file mode 100644 index 0000000..db251dc --- /dev/null +++ b/jetstreampcg/test/conftest.py @@ -0,0 +1,47 @@ +import logging +from collections.abc import AsyncIterator, Iterator + +import pytest +import pytest_asyncio +from nats import connect as nats_connect +from nats.aio.client import Client as NATS +from nats.js import JetStreamContext +from nats.js.errors import Error as JSError +from testcontainers.nats import NatsContainer + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def nats_container() -> Iterator[NatsContainer]: + with NatsContainer(command="-js") as nats_container: # "-js enables jetstream" + yield nats_container + + +@pytest_asyncio.fixture +async def nats_client(nats_container: NatsContainer) -> AsyncIterator[NATS]: + """Fixture providing a connected NATS client""" + nc = await nats_connect(nats_container.nats_uri(), connect_timeout=10.0) + yield nc + await nc.close() + + +@pytest_asyncio.fixture +async def js_client(nats_client: NATS) -> AsyncIterator[JetStreamContext]: + """Fixture providing a JetStream context""" + js = nats_client.jetstream() + yield js + + # Clean up streams after each test + try: + streams = await js.streams_info() + for stream_info in streams: + if stream_info.config.name: + try: + _ = await js.delete_stream(stream_info.config.name) + except JSError as e: + logger.warning( + "Failed to delete stream %s: %s", stream_info.config.name, e + ) + except JSError as e: + logger.warning("Failed to list streams during cleanup: %s", e) diff --git a/jetstreampcg/test/test_common.py b/jetstreampcg/test/test_common.py new file mode 100644 index 0000000..2cc4186 --- /dev/null +++ b/jetstreampcg/test/test_common.py @@ -0,0 +1,228 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for common.py module.""" + +from jetstreampcg.common import ( + MemberMapping, + compose_key, + generate_partition_filters, +) + + +class TestGeneratePartitionFilters: + """Test cases for generate_partition_filters function. + + These tests are ported from orbit.go/pcgroups/partitioned_consumer_groups_test.go + """ + + def test_partition_distribution_6_members_3_consumers(self): + """Test partition distribution with 6 partitions and 3 members.""" + members = ["m1", "m2", "m3"] + max_members = 6 + + # Each member should get 2 partitions + assert generate_partition_filters(members, max_members, [], "m1") == [ + "0.>", + "1.>", + ] + assert generate_partition_filters(members, max_members, [], "m2") == [ + "2.>", + "3.>", + ] + assert generate_partition_filters(members, max_members, [], "m3") == [ + "4.>", + "5.>", + ] + + def test_partition_distribution_7_members_3_consumers(self): + """Test partition distribution with 7 partitions and 3 members. + + With 7 partitions and 3 members, the distribution should be: + - m1: 0, 1, 6 (3 partitions) + - m2: 2, 3 (2 partitions) + - m3: 4, 5 (2 partitions) + """ + members = ["m1", "m2", "m3"] + max_members = 7 + + assert generate_partition_filters(members, max_members, [], "m1") == [ + "0.>", + "1.>", + "6.>", + ] + assert generate_partition_filters(members, max_members, [], "m2") == [ + "2.>", + "3.>", + ] + assert generate_partition_filters(members, max_members, [], "m3") == [ + "4.>", + "5.>", + ] + + def test_partition_distribution_8_members_3_consumers(self): + """Test partition distribution with 8 partitions and 3 members. + + With 8 partitions and 3 members, the distribution should be: + - m1: 0, 1, 6 (3 partitions) + - m2: 2, 3, 7 (3 partitions) + - m3: 4, 5 (2 partitions) + """ + members = ["m1", "m2", "m3"] + max_members = 8 + + assert generate_partition_filters(members, max_members, [], "m1") == [ + "0.>", + "1.>", + "6.>", + ] + assert generate_partition_filters(members, max_members, [], "m2") == [ + "2.>", + "3.>", + "7.>", + ] + assert generate_partition_filters(members, max_members, [], "m3") == [ + "4.>", + "5.>", + ] + + def test_member_not_in_list(self): + """Test behavior when requested member is not in the list.""" + members = ["m1", "m2"] + max_members = 4 + + # Member not in list should get empty filters + assert generate_partition_filters(members, max_members, [], "m3") == [] + + def test_empty_members_list(self): + """Test behavior with empty members list.""" + assert generate_partition_filters([], 4, [], "m1") == [] + + def test_deduplicate_members(self): + """Test that duplicate members are removed.""" + members = ["m1", "m2", "m1", "m2", "m3"] + max_members = 6 + + # Should deduplicate to ["m1", "m2", "m3"] and then sort + assert generate_partition_filters(members, max_members, [], "m1") == [ + "0.>", + "1.>", + ] + assert generate_partition_filters(members, max_members, [], "m2") == [ + "2.>", + "3.>", + ] + assert generate_partition_filters(members, max_members, [], "m3") == [ + "4.>", + "5.>", + ] + + def test_max_members_limit(self): + """Test that member list is truncated to max_members.""" + members = ["m1", "m2", "m3", "m4", "m5"] + max_members = 3 + + # Should only use first 3 members after sorting + assert generate_partition_filters(members, max_members, [], "m1") == ["0.>"] + assert generate_partition_filters(members, max_members, [], "m2") == ["1.>"] + assert generate_partition_filters(members, max_members, [], "m3") == ["2.>"] + # m4 and m5 are ignored + assert generate_partition_filters(members, max_members, [], "m4") == [] + assert generate_partition_filters(members, max_members, [], "m5") == [] + + def test_member_mappings_when_members_empty(self): + """Test that member_mappings is used when members list is empty.""" + member_mappings = [ + MemberMapping(member="m1", partitions=[0, 2]), + MemberMapping(member="m2", partitions=[1, 3]), + ] + max_members = 4 + + # When members is empty, member_mappings is used + assert generate_partition_filters([], max_members, member_mappings, "m1") == [ + "0.>", + "2.>", + ] + assert generate_partition_filters([], max_members, member_mappings, "m2") == [ + "1.>", + "3.>", + ] + + def test_members_takes_precedence_over_member_mappings(self): + """Test that members list takes precedence over member_mappings when both are provided.""" + members = ["m1", "m2"] + member_mappings = [ + MemberMapping(member="m1", partitions=[0, 2]), + MemberMapping(member="m2", partitions=[1, 3]), + ] + max_members = 4 + + # When both are provided, members list is used and member_mappings is ignored + assert generate_partition_filters( + members, max_members, member_mappings, "m1" + ) == ["0.>", "1.>"] + assert generate_partition_filters( + members, max_members, member_mappings, "m2" + ) == ["2.>", "3.>"] + + def test_member_mappings_not_found(self): + """Test member_mappings when member is not found.""" + member_mappings = [ + MemberMapping(member="m1", partitions=[0, 1]), + ] + max_members = 4 + + assert generate_partition_filters([], max_members, member_mappings, "m2") == [] + + def test_single_partition(self): + """Test with single partition and single member.""" + members = ["m1"] + max_members = 1 + + assert generate_partition_filters(members, max_members, [], "m1") == ["0.>"] + + def test_members_sorted(self): + """Test that members are sorted before distribution.""" + members = ["m3", "m1", "m2"] + max_members = 3 + + # After sorting: ["m1", "m2", "m3"] + assert generate_partition_filters(members, max_members, [], "m1") == ["0.>"] + assert generate_partition_filters(members, max_members, [], "m2") == ["1.>"] + assert generate_partition_filters(members, max_members, [], "m3") == ["2.>"] + + +class TestComposeKey: + """Test cases for compose_key function.""" + + def test_compose_key(self): + """Test key composition.""" + assert compose_key("stream1", "group1") == "stream1.group1" + assert compose_key("my.stream", "my.group") == "my.stream.my.group" + assert compose_key("", "") == "." + + +class TestMemberMapping: + """Test cases for MemberMapping dataclass.""" + + def test_member_mapping_creation(self): + """Test creating MemberMapping instances.""" + mapping = MemberMapping(member="m1", partitions=[0, 1, 2]) + assert mapping.member == "m1" + assert mapping.partitions == [0, 1, 2] + + def test_member_mapping_empty_partitions(self): + """Test MemberMapping with empty partitions.""" + mapping = MemberMapping(member="m1", partitions=[]) + assert mapping.member == "m1" + assert mapping.partitions == [] diff --git a/jetstreampcg/test/test_elastic.py b/jetstreampcg/test/test_elastic.py new file mode 100644 index 0000000..d50ecd4 --- /dev/null +++ b/jetstreampcg/test/test_elastic.py @@ -0,0 +1,459 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for elastic.py module.""" + +import pytest + +from jetstreampcg.common import MemberMapping +from jetstreampcg.elastic import ( + ElasticConsumerGroupConfig, + _get_partitioning_transform_dest, + _validate_config, + elastic_get_partition_filters, +) + + +class TestElasticConsumerGroupConfig: + """Test cases for ElasticConsumerGroupConfig class. + + These tests are ported from orbit.go/pcgroups/partitioned_consumer_groups_test.go + """ + + def test_get_partitioning_transform_dest(self): + """Test the partitioning transform destination string generation.""" + config = ElasticConsumerGroupConfig( + max_members=4, + filter="foo.*.*.>", + partitioning_wildcards=[1, 2], + ) + dest = _get_partitioning_transform_dest(config) + expected = "{{Partition(4,1,2)}}.foo.{{Wildcard(1)}}.{{Wildcard(2)}}.>" + assert dest == expected + + def test_elastic_get_partition_filters_6_members(self): + """Test partition filter generation with 6 members and 3 consumers.""" + config = ElasticConsumerGroupConfig( + max_members=6, + filter="foo.*.*.>", + partitioning_wildcards=[1, 2], + members=["m1", "m2", "m3"], + ) + + assert elastic_get_partition_filters(config, "m1") == ["0.>", "1.>"] + assert elastic_get_partition_filters(config, "m2") == ["2.>", "3.>"] + assert elastic_get_partition_filters(config, "m3") == ["4.>", "5.>"] + + def test_elastic_get_partition_filters_7_members(self): + """Test partition filter generation with 7 members and 3 consumers.""" + config = ElasticConsumerGroupConfig( + max_members=7, + filter="foo.*.*.>", + partitioning_wildcards=[1, 2], + members=["m1", "m2", "m3"], + ) + + assert elastic_get_partition_filters(config, "m1") == ["0.>", "1.>", "6.>"] + assert elastic_get_partition_filters(config, "m2") == ["2.>", "3.>"] + assert elastic_get_partition_filters(config, "m3") == ["4.>", "5.>"] + + def test_elastic_get_partition_filters_8_members(self): + """Test partition filter generation with 8 members and 3 consumers.""" + config = ElasticConsumerGroupConfig( + max_members=8, + filter="foo.*.*.>", + partitioning_wildcards=[1, 2], + members=["m1", "m2", "m3"], + ) + + assert elastic_get_partition_filters(config, "m1") == ["0.>", "1.>", "6.>"] + assert elastic_get_partition_filters(config, "m2") == ["2.>", "3.>", "7.>"] + assert elastic_get_partition_filters(config, "m3") == ["4.>", "5.>"] + + def test_is_in_membership_with_members(self): + """Test is_in_membership with members list.""" + config = ElasticConsumerGroupConfig( + max_members=3, + filter="test.*", + partitioning_wildcards=[1], + members=["m1", "m2", "m3"], + ) + assert config.is_in_membership("m1") is True + assert config.is_in_membership("m2") is True + assert config.is_in_membership("m3") is True + assert config.is_in_membership("m4") is False + + def test_is_in_membership_with_member_mappings(self): + """Test is_in_membership with member mappings.""" + config = ElasticConsumerGroupConfig( + max_members=3, + filter="test.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[1, 2]), + ], + ) + assert config.is_in_membership("m1") is True + assert config.is_in_membership("m2") is True + assert config.is_in_membership("m3") is False + + def test_to_dict(self): + """Test conversion to dictionary.""" + config = ElasticConsumerGroupConfig( + max_members=3, + filter="test.*", + partitioning_wildcards=[1], + max_buffered_msgs=100, + max_buffered_bytes=1024, + members=["m1", "m2"], + member_mappings=[MemberMapping(member="m3", partitions=[0, 1])], + ) + result = config.to_dict() + assert result["max_members"] == 3 + assert result["filter"] == "test.*" + assert result["partitioning_wildcards"] == [1] + assert result["max_buffered_msgs"] == 100 + assert result["max_buffered_bytes"] == 1024 + assert result["members"] == ["m1", "m2"] + assert result["member_mappings"] == [{"member": "m3", "partitions": [0, 1]}] + + def test_to_dict_minimal(self): + """Test conversion to dictionary with minimal fields.""" + config = ElasticConsumerGroupConfig( + max_members=3, filter="test.*", partitioning_wildcards=[1] + ) + result = config.to_dict() + assert result["max_members"] == 3 + assert result["filter"] == "test.*" + assert result["partitioning_wildcards"] == [1] + assert "max_buffered_msgs" not in result + assert "max_buffered_bytes" not in result + assert "members" not in result + assert "member_mappings" not in result + + def test_from_dict(self): + """Test creation from dictionary.""" + data = { + "max_members": 3, + "filter": "test.*", + "partitioning_wildcards": [1], + "max_buffered_msgs": 100, + "max_buffered_bytes": 1024, + "members": ["m1", "m2"], + "member_mappings": [{"member": "m3", "partitions": [0, 1]}], + } + config = ElasticConsumerGroupConfig.from_dict(data) + assert config.max_members == 3 + assert config.filter == "test.*" + assert config.partitioning_wildcards == [1] + assert config.max_buffered_msgs == 100 + assert config.max_buffered_bytes == 1024 + assert config.members == ["m1", "m2"] + assert len(config.member_mappings) == 1 + assert config.member_mappings[0].member == "m3" + assert config.member_mappings[0].partitions == [0, 1] + + def test_from_dict_minimal(self): + """Test creation from dictionary with minimal fields.""" + data = { + "max_members": 3, + "filter": "test.*", + "partitioning_wildcards": [1], + } + config = ElasticConsumerGroupConfig.from_dict(data) + assert config.max_members == 3 + assert config.filter == "test.*" + assert config.partitioning_wildcards == [1] + assert config.max_buffered_msgs is None + assert config.max_buffered_bytes is None + assert config.members == [] + assert config.member_mappings == [] + + +class TestValidateElasticConfig: + """Test cases for _validate_config function for elastic consumer groups. + + These tests are ported from orbit.go/pcgroups/partitioned_consumer_groups_test.go:75-111 + """ + + def test_valid_config_with_members(self): + """Test validation with valid members list.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + members=["m1", "m2"], + ) + _validate_config(config) # Should not raise + + def test_valid_config_with_member_mappings(self): + """Test validation with valid member mappings.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[MemberMapping(member="m1", partitions=[0, 1])], + ) + _validate_config(config) # Should not raise + + def test_invalid_both_members_and_mappings(self): + """Test validation fails when both members and member_mappings are provided.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + members=["m1", "m2"], + member_mappings=[MemberMapping(member="m1", partitions=[0, 1])], + ) + with pytest.raises( + ValueError, match="either members or member mappings must be provided" + ): + _validate_config(config) + + def test_invalid_duplicate_partitions(self): + """Test validation fails with duplicate partition numbers.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[MemberMapping(member="m1", partitions=[1, 1])], + ) + with pytest.raises( + ValueError, match="partition numbers must be used only once" + ): + _validate_config(config) + + def test_invalid_insufficient_partitions(self): + """Test validation fails when not all partitions are covered.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[MemberMapping(member="m1", partitions=[1])], + ) + with pytest.raises( + ValueError, + match="number of unique partition numbers must be equal to the max", + ): + _validate_config(config) + + def test_invalid_too_many_partitions(self): + """Test validation fails with too many partitions.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[MemberMapping(member="m1", partitions=[0, 1, 2])], + ) + with pytest.raises( + ValueError, match="partition numbers must be between 0 and one less" + ): + _validate_config(config) + + def test_invalid_partition_out_of_range(self): + """Test validation fails with partition number out of range.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[MemberMapping(member="m1", partitions=[0, 2])], + ) + with pytest.raises( + ValueError, match="partition numbers must be between 0 and one less" + ): + _validate_config(config) + + def test_invalid_duplicate_members(self): + """Test validation fails with duplicate member names.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 1]), + MemberMapping(member="m1", partitions=[0, 1]), + ], + ) + with pytest.raises(ValueError, match="member names must be unique"): + _validate_config(config) + + def test_invalid_partition_overlap(self): + """Test validation fails when partitions overlap between members.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 1]), + MemberMapping(member="m2", partitions=[0, 1]), + ], + ) + with pytest.raises( + ValueError, match="partition numbers must be used only once" + ): + _validate_config(config) + + def test_invalid_member_mappings_out_of_range_high(self): + """Test validation fails when member_mappings partitions are out of range (too high).""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[1]), + ], + ) + _validate_config(config) # Should not raise + + # Now make max_members 3 but keep the same mappings (missing partition 2) + config.max_members = 3 + with pytest.raises( + ValueError, + match="number of unique partition numbers must be equal to the max", + ): + _validate_config(config) + + def test_invalid_partial_overlap(self): + """Test validation fails with partial partition overlap.""" + config = ElasticConsumerGroupConfig( + max_members=3, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 2]), + MemberMapping(member="m2", partitions=[1, 2]), + ], + ) + with pytest.raises( + ValueError, match="partition numbers must be used only once" + ): + _validate_config(config) + + def test_valid_complex_member_mappings(self): + """Test validation with complex but valid member mappings.""" + config = ElasticConsumerGroupConfig( + max_members=3, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 2]), + MemberMapping(member="m2", partitions=[1]), + ], + ) + _validate_config(config) # Should not raise + + def test_invalid_max_members_zero(self): + """Test validation fails with max_members = 0.""" + config = ElasticConsumerGroupConfig( + max_members=0, filter="foo.*", partitioning_wildcards=[1] + ) + with pytest.raises(ValueError, match="max number of members must be >= 1"): + _validate_config(config) + + def test_invalid_max_members_negative(self): + """Test validation fails with negative max_members.""" + config = ElasticConsumerGroupConfig( + max_members=-1, filter="foo.*", partitioning_wildcards=[1] + ) + with pytest.raises(ValueError, match="max number of members must be >= 1"): + _validate_config(config) + + def test_invalid_filter_no_wildcards(self): + """Test validation fails when filter has no wildcards.""" + config = ElasticConsumerGroupConfig( + max_members=2, filter="foo.bar", partitioning_wildcards=[1] + ) + with pytest.raises(ValueError, match="filter must contain at least one"): + _validate_config(config) + + def test_invalid_partitioning_wildcards_too_many(self): + """Test validation fails when partitioning_wildcards exceeds available wildcards.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[1, 2], # Only 1 wildcard in filter + ) + with pytest.raises( + ValueError, match="number of partitioning wildcards must be between" + ): + _validate_config(config) + + def test_invalid_partitioning_wildcards_zero(self): + """Test validation fails when partitioning_wildcards is empty.""" + config = ElasticConsumerGroupConfig( + max_members=2, filter="foo.*", partitioning_wildcards=[] + ) + with pytest.raises( + ValueError, match="number of partitioning wildcards must be between" + ): + _validate_config(config) + + def test_invalid_partitioning_wildcards_index_out_of_range(self): + """Test validation fails when partitioning wildcard index is out of range.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*", + partitioning_wildcards=[2], # Only 1 wildcard, so max index is 1 + ) + with pytest.raises( + ValueError, + match="partitioning wildcard indexes must be between 1 and the number", + ): + _validate_config(config) + + def test_invalid_partitioning_wildcards_duplicate(self): + """Test validation fails when partitioning wildcards has duplicates.""" + config = ElasticConsumerGroupConfig( + max_members=2, + filter="foo.*.bar.*", + partitioning_wildcards=[1, 1], + ) + with pytest.raises( + ValueError, match="partitioning wildcard indexes must be unique" + ): + _validate_config(config) + + def test_invalid_partitioning_wildcards_zero_index(self): + """Test validation fails when partitioning wildcard index is 0.""" + config = ElasticConsumerGroupConfig( + max_members=2, filter="foo.*", partitioning_wildcards=[0] + ) + with pytest.raises( + ValueError, + match="partitioning wildcard indexes must be between 1 and the number", + ): + _validate_config(config) + + def test_valid_multiple_wildcards(self): + """Test validation with multiple wildcards.""" + config = ElasticConsumerGroupConfig( + max_members=4, + filter="foo.*.bar.*.baz.*", + partitioning_wildcards=[1, 2, 3], + ) + _validate_config(config) # Should not raise + + def test_valid_member_mappings_subset(self): + """Test validation with member_mappings covering all partitions but fewer members than max.""" + config = ElasticConsumerGroupConfig( + max_members=4, + filter="foo.*", + partitioning_wildcards=[1], + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 1]), + MemberMapping(member="m2", partitions=[2, 3]), + ], + ) + _validate_config(config) # Should not raise diff --git a/jetstreampcg/test/test_integration.py b/jetstreampcg/test/test_integration.py new file mode 100644 index 0000000..d730c81 --- /dev/null +++ b/jetstreampcg/test/test_integration.py @@ -0,0 +1,326 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for jetstreampcg. + +These tests are ported from orbit.go/pcgroups/test/stream_consumer_group_test.go +""" + +import asyncio + +import pytest +from nats.js import JetStreamContext +from nats.js.api import AckPolicy, ConsumerConfig, StreamConfig, SubjectTransform + +from jetstreampcg.elastic import ( + add_members, + create_elastic, + delete_elastic, + delete_members, +) +from jetstreampcg.static import create_static, delete_static, static_consume + + +@pytest.mark.asyncio +class TestStaticIntegration: + """Integration tests for static consumer groups. + + Ported from orbit.go/pcgroups/test/stream_consumer_group_test.go:TestStatic + """ + + async def test_static_consumer_group(self, js_client: JetStreamContext): + """Test static consumer group with two members consuming messages in parallel.""" + stream_name = "test-static" + cg_name = "group" + c1_count = 0 + c2_count = 0 + + # Create a stream with subject transform for partitioning + await js_client.add_stream( + StreamConfig( + name=stream_name, + subjects=["bar.*"], + subject_transform=SubjectTransform( + src="bar.*", + dest="{{partition(2,1)}}.bar.{{wildcard(1)}}", + ), + ) + ) + + # Publish 10 messages + for i in range(10): + await js_client.publish(f"bar.{i}", b"payload") + + # Consumer config + consumer_config = ConsumerConfig( + max_ack_pending=1, + ack_wait=1.0, + ack_policy=AckPolicy.EXPLICIT, + ) + + # Create static consumer group with 2 members + await create_static( + js_client, + stream_name, + cg_name, + max_members=2, + filter="bar.*", + members=["m1", "m2"], + member_mappings=[], + ) + + # Track when to stop consuming + stop_event = asyncio.Event() + + # Consumer 1 + async def consume_m1(): + nonlocal c1_count + + async def m1_handler(msg): + nonlocal c1_count + c1_count += 1 + await msg.ack() + + ctx = await static_consume( + js_client, + stream_name, + cg_name, + "m1", + m1_handler, + consumer_config, + ) + + # Wait for stop signal + await stop_event.wait() + ctx.stop() + await ctx.done() + + # Consumer 2 + async def consume_m2(): + nonlocal c2_count + + async def m2_handler(msg): + nonlocal c2_count + c2_count += 1 + await msg.ack() + + ctx = await static_consume( + js_client, + stream_name, + cg_name, + "m2", + m2_handler, + consumer_config, + ) + + # Wait for stop signal + await stop_event.wait() + ctx.stop() + await ctx.done() + + # Start both consumers + task1 = asyncio.create_task(consume_m1()) + task2 = asyncio.create_task(consume_m2()) + + # Wait for all messages to be consumed (with timeout) + start_time = asyncio.get_event_loop().time() + while c1_count + c2_count < 10: + await asyncio.sleep(0.1) + if asyncio.get_event_loop().time() - start_time > 5: + pytest.fail("Timeout waiting for messages to be consumed") + + # Signal consumers to stop + stop_event.set() + + # Wait for consumers to finish + await asyncio.gather(task1, task2) + + # Verify all messages were consumed + assert c1_count + c2_count == 10 + + # Clean up + await delete_static(js_client, stream_name, cg_name) + + +@pytest.mark.asyncio +class TestElasticIntegration: + """Integration tests for elastic consumer groups. + + Ported from orbit.go/pcgroups/test/stream_consumer_group_test.go:TestElastic + """ + + async def test_elastic_consumer_group_with_membership_changes( + self, js_client: JetStreamContext + ): + """Test elastic consumer group with dynamic member addition and removal.""" + stream_name = "test-elastic" + cg_name = "group" + c1_count = 0 + c2_count = 0 + + # Create a stream + await js_client.add_stream( + StreamConfig( + name=stream_name, + subjects=["bar.*"], + ) + ) + + # Publish 10 messages + for i in range(10): + await js_client.publish(f"bar.{i}", b"payload") + + # Consumer config + consumer_config = ConsumerConfig( + max_ack_pending=1, + ack_wait=1.0, + ack_policy=AckPolicy.EXPLICIT, + ) + + # Create elastic consumer group with max 2 members + await create_elastic( + js_client, + stream_name, + cg_name, + max_num_members=2, + filter="bar.*", + partitioning_wildcards=[1], + ) + + # Track when to stop consuming + stop_event_m1 = asyncio.Event() + stop_event_m2 = asyncio.Event() + + # Consumer 1 + async def consume_m1(): + nonlocal c1_count + + async def m1_handler(msg): + nonlocal c1_count + c1_count += 1 + await msg.ack() + + from jetstreampcg.elastic import elastic_consume + + ctx = await elastic_consume( + js_client, + stream_name, + cg_name, + "m1", + m1_handler, + consumer_config, + ) + + # Wait for stop signal + await stop_event_m1.wait() + ctx.stop() + await ctx.done() + + # Consumer 2 + async def consume_m2(): + nonlocal c2_count + + async def m2_handler(msg): + nonlocal c2_count + c2_count += 1 + await msg.ack() + + from jetstreampcg.elastic import elastic_consume + + ctx = await elastic_consume( + js_client, + stream_name, + cg_name, + "m2", + m2_handler, + consumer_config, + ) + + # Wait for stop signal + await stop_event_m2.wait() + ctx.stop() + await ctx.done() + + # Start both consumers + task1 = asyncio.create_task(consume_m1()) + task2 = asyncio.create_task(consume_m2()) + + # Add only m1 to membership + await add_members(js_client, stream_name, cg_name, ["m1"]) + + # Wait for m1 to consume all 10 messages (m2 should not consume any) + start_time = asyncio.get_event_loop().time() + while c1_count != 10 or c2_count != 0: + await asyncio.sleep(0.1) + if asyncio.get_event_loop().time() - start_time > 5: + pytest.fail( + f"Timeout: expected c1=10, c2=0, got c1={c1_count}, c2={c2_count}" + ) + + assert c1_count == 10 + assert c2_count == 0 + + # Add m2 to membership + await add_members(js_client, stream_name, cg_name, ["m2"]) + + # Wait a bit for m2 to be effectively added + await asyncio.sleep(0.05) + + # Publish 10 more messages + for i in range(10): + await js_client.publish(f"bar.{i}", b"payload") + + # Wait for messages to be split between m1 and m2 + start_time = asyncio.get_event_loop().time() + while c1_count + c2_count < 20: + await asyncio.sleep(0.1) + if asyncio.get_event_loop().time() - start_time > 10: + pytest.fail( + f"Timeout: expected total=20, got c1={c1_count}, c2={c2_count}" + ) + + # Both should have consumed some messages (split between them) + assert c1_count == 15 + assert c2_count == 5 + + # Remove m1 from membership + await delete_members(js_client, stream_name, cg_name, ["m1"]) + + # Wait a bit for m1 to be effectively deleted + await asyncio.sleep(0.05) + + # Publish 10 more messages + for i in range(10): + await js_client.publish(f"bar.{i}", b"payload") + + # Wait for m2 to consume all new messages (m1 should not consume any more) + start_time = asyncio.get_event_loop().time() + while c1_count != 15 or c2_count != 15: + await asyncio.sleep(0.1) + if asyncio.get_event_loop().time() - start_time > 10: + pytest.fail( + f"Timeout: expected c1=15, c2=15, got c1={c1_count}, c2={c2_count}" + ) + + assert c1_count == 15 + assert c2_count == 15 + + # Signal consumers to stop + stop_event_m1.set() + stop_event_m2.set() + + # Wait for consumers to finish + await asyncio.gather(task1, task2) + + # Clean up + await delete_elastic(js_client, stream_name, cg_name) diff --git a/jetstreampcg/test/test_static.py b/jetstreampcg/test/test_static.py new file mode 100644 index 0000000..6b3deff --- /dev/null +++ b/jetstreampcg/test/test_static.py @@ -0,0 +1,220 @@ +# Copyright 2025 Oliver Lambson +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for static.py module.""" + +import pytest + +from jetstreampcg.common import MemberMapping +from jetstreampcg.static import StaticConsumerGroupConfig, validate_static_config + + +class TestStaticConsumerGroupConfig: + """Test cases for StaticConsumerGroupConfig class.""" + + def test_is_in_membership_with_members(self): + """Test is_in_membership with members list.""" + config = StaticConsumerGroupConfig( + max_members=3, filter="test.*", members=["m1", "m2", "m3"] + ) + assert config.is_in_membership("m1") is True + assert config.is_in_membership("m2") is True + assert config.is_in_membership("m3") is True + assert config.is_in_membership("m4") is False + + def test_is_in_membership_with_member_mappings(self): + """Test is_in_membership with member mappings.""" + config = StaticConsumerGroupConfig( + max_members=3, + filter="test.*", + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[1, 2]), + ], + ) + assert config.is_in_membership("m1") is True + assert config.is_in_membership("m2") is True + assert config.is_in_membership("m3") is False + + def test_to_dict(self): + """Test conversion to dictionary.""" + config = StaticConsumerGroupConfig( + max_members=3, + filter="test.*", + members=["m1", "m2"], + member_mappings=[MemberMapping(member="m3", partitions=[0, 1])], + ) + result = config.to_dict() + assert result["max_members"] == 3 + assert result["filter"] == "test.*" + assert result["members"] == ["m1", "m2"] + assert result["member_mappings"] == [{"member": "m3", "partitions": [0, 1]}] + + def test_to_dict_minimal(self): + """Test conversion to dictionary with minimal fields.""" + config = StaticConsumerGroupConfig(max_members=3) + result = config.to_dict() + assert result == {"max_members": 3, "filter": ""} + assert "members" not in result + assert "member_mappings" not in result + + def test_from_dict(self): + """Test creation from dictionary.""" + data = { + "max_members": 3, + "filter": "test.*", + "members": ["m1", "m2"], + "member_mappings": [{"member": "m3", "partitions": [0, 1]}], + } + config = StaticConsumerGroupConfig.from_dict(data) + assert config.max_members == 3 + assert config.filter == "test.*" + assert config.members == ["m1", "m2"] + assert len(config.member_mappings) == 1 + assert config.member_mappings[0].member == "m3" + assert config.member_mappings[0].partitions == [0, 1] + + def test_from_dict_minimal(self): + """Test creation from dictionary with minimal fields.""" + data = {"max_members": 3} + config = StaticConsumerGroupConfig.from_dict(data) + assert config.max_members == 3 + assert config.filter == "" + assert config.members == [] + assert config.member_mappings == [] + + +class TestValidateStaticConfig: + """Test cases for validate_static_config function.""" + + def test_valid_config_with_members(self): + """Test validation with valid members list.""" + config = StaticConsumerGroupConfig( + max_members=3, filter="test.*", members=["m1", "m2", "m3"] + ) + validate_static_config(config) # Should not raise + + def test_valid_config_with_member_mappings(self): + """Test validation with valid member mappings.""" + config = StaticConsumerGroupConfig( + max_members=3, + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[1]), + MemberMapping(member="m3", partitions=[2]), + ], + ) + validate_static_config(config) # Should not raise + + def test_invalid_max_members(self): + """Test validation fails with invalid max_members.""" + config = StaticConsumerGroupConfig(max_members=0) + with pytest.raises(ValueError, match="max number of members must be >= 1"): + validate_static_config(config) + + def test_both_members_and_mappings(self): + """Test validation fails with both members and member_mappings.""" + config = StaticConsumerGroupConfig( + max_members=3, + members=["m1"], + member_mappings=[MemberMapping(member="m2", partitions=[0])], + ) + with pytest.raises( + ValueError, match="either members or member mappings must be provided" + ): + validate_static_config(config) + + def test_member_mappings_too_many(self): + """Test validation fails with too many member mappings.""" + config = StaticConsumerGroupConfig( + max_members=2, + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[1]), + MemberMapping(member="m3", partitions=[]), + ], + ) + with pytest.raises( + ValueError, + match="number of member mappings must be between 1 and the max number", + ): + validate_static_config(config) + + def test_duplicate_member_names(self): + """Test validation fails with duplicate member names in mappings.""" + config = StaticConsumerGroupConfig( + max_members=3, + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m1", partitions=[1]), + MemberMapping(member="m2", partitions=[2]), + ], + ) + with pytest.raises(ValueError, match="member names must be unique"): + validate_static_config(config) + + def test_duplicate_partitions(self): + """Test validation fails with duplicate partition numbers.""" + config = StaticConsumerGroupConfig( + max_members=3, + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 1]), + MemberMapping(member="m2", partitions=[1, 2]), + ], + ) + with pytest.raises( + ValueError, match="partition numbers must be used only once" + ): + validate_static_config(config) + + def test_partition_out_of_range(self): + """Test validation fails with partition number out of range.""" + config = StaticConsumerGroupConfig( + max_members=3, + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[3]), # Out of range + MemberMapping(member="m3", partitions=[2]), + ], + ) + with pytest.raises( + ValueError, match="partition numbers must be between 0 and one less" + ): + validate_static_config(config) + + def test_missing_partitions(self): + """Test validation fails when not all partitions are covered.""" + config = StaticConsumerGroupConfig( + max_members=3, + member_mappings=[ + MemberMapping(member="m1", partitions=[0]), + MemberMapping(member="m2", partitions=[2]), + # Missing partition 1 + ], + ) + with pytest.raises( + ValueError, + match="number of unique partition numbers must be equal to the max", + ): + validate_static_config(config) + + def test_valid_complex_member_mappings(self): + """Test validation with complex but valid member mappings.""" + config = StaticConsumerGroupConfig( + max_members=6, + member_mappings=[ + MemberMapping(member="m1", partitions=[0, 1, 2]), + MemberMapping(member="m2", partitions=[3, 4, 5]), + ], + ) + validate_static_config(config) # Should not raise diff --git a/jetstreampcg/uv.lock b/jetstreampcg/uv.lock new file mode 100644 index 0000000..299486b --- /dev/null +++ b/jetstreampcg/uv.lock @@ -0,0 +1,499 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.31.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/53/570b03ec0445a9b2cc69788482c1d12902a9b88a9b159e449c4c537c4e3a/basedpyright-1.31.4.tar.gz", hash = "sha256:2450deb16530f7c88c1a7da04530a079f9b0b18ae1c71cb6f812825b3b82d0b1", size = 22494467, upload-time = "2025-09-03T13:05:55.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/40/d1047a5addcade9291685d06ef42a63c1347517018bafd82747af9da0294/basedpyright-1.31.4-py3-none-any.whl", hash = "sha256:055e4a38024bd653be12d6216c1cfdbee49a1096d342b4d5f5b4560f7714b6fc", size = 11731440, upload-time = "2025-09-03T13:05:52.308Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jetstreampcg" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "nats-py" }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "testcontainers" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [{ name = "nats-py", git = "https://www.github.com/oliverlambson/nats.py?subdirectory=nats&rev=1181018c80476413ade3f8849980afa3c8835ebd" }] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.31.4" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.1.0" }, + { name = "ruff", specifier = ">=0.12.12" }, + { name = "testcontainers", specifier = ">=4.12.0" }, + { name = "ty", specifier = ">=0.0.1a20" }, +] + +[[package]] +name = "nats-py" +version = "2.11.0" +source = { git = "https://www.github.com/oliverlambson/nats.py?subdirectory=nats&rev=1181018c80476413ade3f8849980afa3c8835ebd#1181018c80476413ade3f8849980afa3c8835ebd" } + +[[package]] +name = "nodejs-wheel-binaries" +version = "22.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/ca/6033f80b7aebc23cb31ed8b09608b6308c5273c3522aedd043e8a0644d83/nodejs_wheel_binaries-22.19.0.tar.gz", hash = "sha256:e69b97ef443d36a72602f7ed356c6a36323873230f894799f4270a853932fdb3", size = 8060, upload-time = "2025-09-12T10:33:46.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/a2/0d055fd1d8c9a7a971c4db10cf42f3bba57c964beb6cf383ca053f2cdd20/nodejs_wheel_binaries-22.19.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:43eca1526455a1fb4cb777095198f7ebe5111a4444749c87f5c2b84645aaa72a", size = 50902454, upload-time = "2025-09-12T10:33:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f5/446f7b3c5be1d2f5145ffa3c9aac3496e06cdf0f436adeb21a1f95dd79a7/nodejs_wheel_binaries-22.19.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:feb06709e1320790d34babdf71d841ec7f28e4c73217d733e7f5023060a86bfc", size = 51837860, upload-time = "2025-09-12T10:33:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/d0a036f04fd0f5dc3ae505430657044b8d9853c33be6b2d122bb171aaca3/nodejs_wheel_binaries-22.19.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db9f5777292491430457c99228d3a267decf12a09d31246f0692391e3513285e", size = 57841528, upload-time = "2025-09-12T10:33:25.433Z" }, + { url = "https://files.pythonhosted.org/packages/e2/11/4811d27819f229cc129925c170db20c12d4f01ad366a0066f06d6eb833cf/nodejs_wheel_binaries-22.19.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1392896f1a05a88a8a89b26e182d90fdf3020b4598a047807b91b65731e24c00", size = 58368815, upload-time = "2025-09-12T10:33:29.083Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/df41416856b980e38a7ff280cfb59f142a77955ccdbec7cc4260d8ab2e78/nodejs_wheel_binaries-22.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9164c876644f949cad665e3ada00f75023e18f381e78a1d7b60ccbbfb4086e73", size = 59690937, upload-time = "2025-09-12T10:33:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/d1/39/8d0d5f84b7616bdc4eca725f5d64a1cfcac3d90cf3f30cae17d12f8e987f/nodejs_wheel_binaries-22.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6b4b75166134010bc9cfebd30dc57047796a27049fef3fc22316216d76bc0af7", size = 60751996, upload-time = "2025-09-12T10:33:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/41/93/2d66b5b60055dd1de6e37e35bef563c15e4cafa5cfe3a6990e0ab358e515/nodejs_wheel_binaries-22.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:3f271f5abfc71b052a6b074225eca8c1223a0f7216863439b86feaca814f6e5a", size = 40026140, upload-time = "2025-09-12T10:33:40.33Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/c9cf7ff7e3c71f07ca8331c939afd09b6e59fc85a2944ea9411e8b29ce50/nodejs_wheel_binaries-22.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:666a355fe0c9bde44a9221cd543599b029045643c8196b8eedb44f28dc192e06", size = 38804500, upload-time = "2025-09-12T10:33:43.302Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/62/01d9f648e9b943175e0dcddf749cf31c769665d8ba08df1e989427163f33/testcontainers-4.12.0.tar.gz", hash = "sha256:13ee89cae995e643f225665aad8b200b25c4f219944a6f9c0b03249ec3f31b8d", size = 66631, upload-time = "2025-07-21T20:32:26.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e8/9e2c392e5d671afda47b917597cac8fde6a452f5776c4c9ceb93fbd2889f/testcontainers-4.12.0-py3-none-any.whl", hash = "sha256:26caef57e642d5e8c5fcc593881cf7df3ab0f0dc9170fad22765b184e226ab15", size = 111791, upload-time = "2025-07-21T20:32:25.038Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "ty" +version = "0.0.1a20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/82/a5e3b4bc5280ec49c4b0b43d0ff727d58c7df128752c9c6f97ad0b5f575f/ty-0.0.1a20.tar.gz", hash = "sha256:933b65a152f277aa0e23ba9027e5df2c2cc09e18293e87f2a918658634db5f15", size = 4194773, upload-time = "2025-09-03T12:35:46.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/c8/f7d39392043d5c04936f6cad90e50eb661965ed092ca4bfc01db917d7b8a/ty-0.0.1a20-py3-none-linux_armv6l.whl", hash = "sha256:f73a7aca1f0d38af4d6999b375eb00553f3bfcba102ae976756cc142e14f3450", size = 8443599, upload-time = "2025-09-03T12:35:04.289Z" }, + { url = "https://files.pythonhosted.org/packages/1e/57/5aec78f9b8a677b7439ccded7d66c3361e61247e0f6b14e659b00dd01008/ty-0.0.1a20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cad12c857ea4b97bf61e02f6796e13061ccca5e41f054cbd657862d80aa43bae", size = 8618102, upload-time = "2025-09-03T12:35:07.448Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/50c9107d93cdb55676473d9dc4e2339af6af606660c9428d3b86a1b2a476/ty-0.0.1a20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f153b65c7fcb6b8b59547ddb6353761b3e8d8bb6f0edd15e3e3ac14405949f7a", size = 8192167, upload-time = "2025-09-03T12:35:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/85/28/018b2f330109cee19e81c5ca9df3dc29f06c5778440eb9af05d4550c4302/ty-0.0.1a20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8c4336987a6a781d4392a9fd7b3a39edb7e4f3dd4f860e03f46c932b52aefa2", size = 8349256, upload-time = "2025-09-03T12:35:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c9/2f8797a05587158f52b142278796ffd72c893bc5ad41840fce5aeb65c6f2/ty-0.0.1a20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ff75cd4c744d09914e8c9db8d99e02f82c9379ad56b0a3fc4c5c9c923cfa84e", size = 8271214, upload-time = "2025-09-03T12:35:13.741Z" }, + { url = "https://files.pythonhosted.org/packages/30/d4/2cac5e5eb9ee51941358cb3139aadadb59520cfaec94e4fcd2b166969748/ty-0.0.1a20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e26437772be7f7808868701f2bf9e14e706a6ec4c7d02dbd377ff94d7ba60c11", size = 9264939, upload-time = "2025-09-03T12:35:16.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/96/a6f2b54e484b2c6a5488f217882237dbdf10f0fdbdb6cd31333d57afe494/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:83a7ee12465841619b5eb3ca962ffc7d576bb1c1ac812638681aee241acbfbbe", size = 9743137, upload-time = "2025-09-03T12:35:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/95b40dcbec3d222f3af5fe5dd1ce066d42f8a25a2f70d5724490457048e7/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:726d0738be4459ac7ffae312ba96c5f486d6cbc082723f322555d7cba9397871", size = 9368153, upload-time = "2025-09-03T12:35:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/2c/24/689fa4c4270b9ef9a53dc2b1d6ffade259ba2c4127e451f0629e130ea46a/ty-0.0.1a20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b481f26513f38543df514189fb16744690bcba8d23afee95a01927d93b46e36", size = 9099637, upload-time = "2025-09-03T12:35:24.94Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/913011cbf3ea4030097fb3c4ce751856114c9e1a5e1075561a4c5242af9b/ty-0.0.1a20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abbe3c02218c12228b1d7c5f98c57240029cc3bcb15b6997b707c19be3908c1", size = 8952000, upload-time = "2025-09-03T12:35:27.288Z" }, + { url = "https://files.pythonhosted.org/packages/df/f9/f5ba2ae455b20c5bb003f9940ef8142a8c4ed9e27de16e8f7472013609db/ty-0.0.1a20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fff51c75ee3f7cc6d7722f2f15789ef8ffe6fd2af70e7269ac785763c906688e", size = 8217938, upload-time = "2025-09-03T12:35:29.54Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/17002cf9032f0981cdb8c898d02422c095c30eefd69ca62a8b705d15bd0f/ty-0.0.1a20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b4124ab75e0e6f09fe7bc9df4a77ee43c5e0ef7e61b0c149d7c089d971437cbd", size = 8292369, upload-time = "2025-09-03T12:35:31.748Z" }, + { url = "https://files.pythonhosted.org/packages/28/d6/0879b1fb66afe1d01d45c7658f3849aa641ac4ea10679404094f3b40053e/ty-0.0.1a20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8a138fa4f74e6ed34e9fd14652d132409700c7ff57682c2fed656109ebfba42f", size = 8811973, upload-time = "2025-09-03T12:35:33.997Z" }, + { url = "https://files.pythonhosted.org/packages/60/1e/70bf0348cfe8ba5f7532983f53c508c293ddf5fa9f942ed79a3c4d576df3/ty-0.0.1a20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8eff8871d6b88d150e2a67beba2c57048f20c090c219f38ed02eebaada04c124", size = 9010990, upload-time = "2025-09-03T12:35:36.766Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ca/03d85c7650359247b1ca3f38a0d869a608ef540450151920e7014ed58292/ty-0.0.1a20-py3-none-win32.whl", hash = "sha256:3c2ace3a22fab4bd79f84c74e3dab26e798bfba7006bea4008d6321c1bd6efc6", size = 8100746, upload-time = "2025-09-03T12:35:40.007Z" }, + { url = "https://files.pythonhosted.org/packages/94/53/7a1937b8c7a66d0c8ed7493de49ed454a850396fe137d2ae12ed247e0b2f/ty-0.0.1a20-py3-none-win_amd64.whl", hash = "sha256:f41e77ff118da3385915e13c3f366b3a2f823461de54abd2e0ca72b170ba0f19", size = 8748861, upload-time = "2025-09-03T12:35:42.175Z" }, + { url = "https://files.pythonhosted.org/packages/27/36/5a3a70c5d497d3332f9e63cabc9c6f13484783b832fecc393f4f1c0c4aa8/ty-0.0.1a20-py3-none-win_arm64.whl", hash = "sha256:d8ac1c5a14cda5fad1a8b53959d9a5d979fe16ce1cc2785ea8676fed143ac85f", size = 8269906, upload-time = "2025-09-03T12:35:45.045Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +]