diff --git a/apps/hip-3-pusher/Dockerfile b/apps/hip-3-pusher/Dockerfile index 74d93c6dbf..74284a267d 100644 --- a/apps/hip-3-pusher/Dockerfile +++ b/apps/hip-3-pusher/Dockerfile @@ -30,4 +30,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev # Run the app by default -CMD ["uv", "run", "src/main.py", "--config", "config/config.toml"] +CMD ["uv", "run", "src/pusher/main.py", "--config", "config/config.toml"] diff --git a/apps/hip-3-pusher/pyproject.toml b/apps/hip-3-pusher/pyproject.toml index 04358bc306..fa22b3f798 100644 --- a/apps/hip-3-pusher/pyproject.toml +++ b/apps/hip-3-pusher/pyproject.toml @@ -5,12 +5,18 @@ description = "Hyperliquid HIP-3 market oracle pusher" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "boto3~=1.40.34", + "boto3~=1.40.38", "cryptography~=46.0.1", "hyperliquid-python-sdk~=0.19.0", "loguru~=0.7.3", "opentelemetry-exporter-prometheus~=0.58b0", "opentelemetry-sdk~=1.37.0", "prometheus-client~=0.23.1", + "tenacity~=9.1.2", "websockets~=15.0.1", ] + +[dependency-groups] +dev = [ + "pytest~=8.4.2", +] diff --git a/apps/hip-3-pusher/src/config.py b/apps/hip-3-pusher/src/pusher/config.py similarity index 97% rename from apps/hip-3-pusher/src/config.py rename to apps/hip-3-pusher/src/pusher/config.py index 289bd455cb..4eeabe37c7 100644 --- a/apps/hip-3-pusher/src/config.py +++ b/apps/hip-3-pusher/src/pusher/config.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +STALE_TIMEOUT_SECONDS = 5 + class KMSConfig(BaseModel): enable_kms: bool diff --git a/apps/hip-3-pusher/src/pusher/exception.py b/apps/hip-3-pusher/src/pusher/exception.py new file mode 100644 index 0000000000..7dd48d5555 --- /dev/null +++ b/apps/hip-3-pusher/src/pusher/exception.py @@ -0,0 +1,2 @@ +class StaleConnection(Exception): + pass diff --git a/apps/hip-3-pusher/src/hermes_listener.py b/apps/hip-3-pusher/src/pusher/hermes_listener.py similarity index 73% rename from apps/hip-3-pusher/src/hermes_listener.py rename to apps/hip-3-pusher/src/pusher/hermes_listener.py index 53500e0b5c..f9811fa4e4 100644 --- a/apps/hip-3-pusher/src/hermes_listener.py +++ b/apps/hip-3-pusher/src/pusher/hermes_listener.py @@ -3,9 +3,11 @@ from loguru import logger import time import websockets +from tenacity import retry, retry_if_exception_type, wait_exponential -from config import Config -from price_state import PriceState, PriceUpdate +from pusher.config import Config, STALE_TIMEOUT_SECONDS +from pusher.exception import StaleConnection +from pusher.price_state import PriceState, PriceUpdate class HermesListener: @@ -31,14 +33,13 @@ def get_subscribe_request(self): async def subscribe_all(self): await asyncio.gather(*(self.subscribe_single(url) for url in self.hermes_urls)) + @retry( + retry=retry_if_exception_type((StaleConnection, websockets.ConnectionClosed)), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) async def subscribe_single(self, url): - while True: - try: - await self.subscribe_single_inner(url) - except websockets.ConnectionClosed: - logger.error("Connection to {} closed; retrying", url) - except Exception as e: - logger.exception("Error on {}: {}", url, e) + return await self.subscribe_single_inner(url) async def subscribe_single_inner(self, url): async with websockets.connect(url) as ws: @@ -48,12 +49,19 @@ async def subscribe_single_inner(self, url): logger.info("Sent Hermes subscribe request to {}", url) # listen for updates - async for message in ws: + while True: try: + message = await asyncio.wait_for(ws.recv(), timeout=STALE_TIMEOUT_SECONDS) data = json.loads(message) self.parse_hermes_message(data) + except asyncio.TimeoutError: + raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") + except websockets.ConnectionClosed: + raise except json.JSONDecodeError as e: logger.error("Failed to decode JSON message: {}", e) + except Exception as e: + logger.error("Unexpected exception: {}", e) def parse_hermes_message(self, data): """ diff --git a/apps/hip-3-pusher/src/hyperliquid_listener.py b/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py similarity index 76% rename from apps/hip-3-pusher/src/hyperliquid_listener.py rename to apps/hip-3-pusher/src/pusher/hyperliquid_listener.py index db231e7a32..60f8cb117f 100644 --- a/apps/hip-3-pusher/src/hyperliquid_listener.py +++ b/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py @@ -2,10 +2,12 @@ import json import websockets from loguru import logger +from tenacity import retry, retry_if_exception_type, wait_exponential import time -from config import Config -from price_state import PriceState, PriceUpdate +from pusher.config import Config, STALE_TIMEOUT_SECONDS +from pusher.exception import StaleConnection +from pusher.price_state import PriceState, PriceUpdate # This will be in config, but note here. # Other RPC providers exist but so far we've seen their support is incomplete. @@ -32,14 +34,13 @@ def get_subscribe_request(self, asset): async def subscribe_all(self): await asyncio.gather(*(self.subscribe_single(hyperliquid_ws_url) for hyperliquid_ws_url in self.hyperliquid_ws_urls)) + @retry( + retry=retry_if_exception_type((StaleConnection, websockets.ConnectionClosed)), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) async def subscribe_single(self, url): - while True: - try: - await self.subscribe_single_inner(url) - except websockets.ConnectionClosed: - logger.error("Connection to {} closed; retrying", url) - except Exception as e: - logger.exception("Error on {}: {}", url, e) + return await self.subscribe_single_inner(url) async def subscribe_single_inner(self, url): async with websockets.connect(url) as ws: @@ -48,8 +49,9 @@ async def subscribe_single_inner(self, url): logger.info("Sent subscribe request to {}", url) # listen for updates - async for message in ws: + while True: try: + message = await asyncio.wait_for(ws.recv(), timeout=STALE_TIMEOUT_SECONDS) data = json.loads(message) channel = data.get("channel", None) if not channel: @@ -62,8 +64,14 @@ async def subscribe_single_inner(self, url): self.parse_hyperliquid_ws_message(data) else: logger.error("Received unknown channel: {}", channel) + except asyncio.TimeoutError: + raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") + except websockets.ConnectionClosed: + raise except json.JSONDecodeError as e: logger.error("Failed to decode JSON message: {} error: {}", message, e) + except Exception as e: + logger.error("Unexpected exception: {}", e) def parse_hyperliquid_ws_message(self, message): try: diff --git a/apps/hip-3-pusher/src/kms_signer.py b/apps/hip-3-pusher/src/pusher/kms_signer.py similarity index 99% rename from apps/hip-3-pusher/src/kms_signer.py rename to apps/hip-3-pusher/src/pusher/kms_signer.py index b7d9c97146..d91582c44c 100644 --- a/apps/hip-3-pusher/src/kms_signer.py +++ b/apps/hip-3-pusher/src/pusher/kms_signer.py @@ -10,7 +10,7 @@ from hyperliquid.utils.signing import get_timestamp_ms, action_hash, construct_phantom_agent, l1_payload from loguru import logger -from config import Config +from pusher.config import Config SECP256K1_N_HALF = SECP256K1_N // 2 diff --git a/apps/hip-3-pusher/src/lazer_listener.py b/apps/hip-3-pusher/src/pusher/lazer_listener.py similarity index 75% rename from apps/hip-3-pusher/src/lazer_listener.py rename to apps/hip-3-pusher/src/pusher/lazer_listener.py index f173db5636..2e5adca52e 100644 --- a/apps/hip-3-pusher/src/lazer_listener.py +++ b/apps/hip-3-pusher/src/pusher/lazer_listener.py @@ -3,9 +3,11 @@ from loguru import logger import time import websockets +from tenacity import retry, retry_if_exception_type, wait_exponential -from config import Config -from price_state import PriceState, PriceUpdate +from pusher.config import Config, STALE_TIMEOUT_SECONDS +from pusher.exception import StaleConnection +from pusher.price_state import PriceState, PriceUpdate class LazerListener: @@ -35,14 +37,13 @@ def get_subscribe_request(self, subscription_id: int): async def subscribe_all(self): await asyncio.gather(*(self.subscribe_single(router_url) for router_url in self.lazer_urls)) + @retry( + retry=retry_if_exception_type((StaleConnection, websockets.ConnectionClosed)), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) async def subscribe_single(self, router_url): - while True: - try: - await self.subscribe_single_inner(router_url) - except websockets.ConnectionClosed: - logger.error("Connection to {} closed; retrying", router_url) - except Exception as e: - logger.exception("Error on {}: {}", router_url, e) + return await self.subscribe_single_inner(router_url) async def subscribe_single_inner(self, router_url): headers = { @@ -56,12 +57,19 @@ async def subscribe_single_inner(self, router_url): logger.info("Sent Lazer subscribe request to {}", router_url) # listen for updates - async for message in ws: + while True: try: + message = await asyncio.wait_for(ws.recv(), timeout=STALE_TIMEOUT_SECONDS) data = json.loads(message) self.parse_lazer_message(data) + except asyncio.TimeoutError: + raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") + except websockets.ConnectionClosed: + raise except json.JSONDecodeError as e: logger.error("Failed to decode JSON message: {}", e) + except Exception as e: + logger.error("Unexpected exception: {}", e) def parse_lazer_message(self, data): """ diff --git a/apps/hip-3-pusher/src/main.py b/apps/hip-3-pusher/src/pusher/main.py similarity index 83% rename from apps/hip-3-pusher/src/main.py rename to apps/hip-3-pusher/src/pusher/main.py index dbb7e38772..837d63bccc 100644 --- a/apps/hip-3-pusher/src/main.py +++ b/apps/hip-3-pusher/src/pusher/main.py @@ -5,13 +5,13 @@ import sys import tomllib -from config import Config -from hyperliquid_listener import HyperliquidListener -from lazer_listener import LazerListener -from hermes_listener import HermesListener -from price_state import PriceState -from publisher import Publisher -from metrics import Metrics +from pusher.config import Config +from pusher.hyperliquid_listener import HyperliquidListener +from pusher.lazer_listener import LazerListener +from pusher.hermes_listener import HermesListener +from pusher.price_state import PriceState +from pusher.publisher import Publisher +from pusher.metrics import Metrics def load_config(): diff --git a/apps/hip-3-pusher/src/metrics.py b/apps/hip-3-pusher/src/pusher/metrics.py similarity index 97% rename from apps/hip-3-pusher/src/metrics.py rename to apps/hip-3-pusher/src/pusher/metrics.py index 66dc78a4df..22338649e9 100644 --- a/apps/hip-3-pusher/src/metrics.py +++ b/apps/hip-3-pusher/src/pusher/metrics.py @@ -3,7 +3,7 @@ from opentelemetry.metrics import get_meter_provider, set_meter_provider from opentelemetry.sdk.metrics import MeterProvider -from config import Config +from pusher.config import Config METER_NAME = "hip3pusher" diff --git a/apps/hip-3-pusher/src/price_state.py b/apps/hip-3-pusher/src/pusher/price_state.py similarity index 99% rename from apps/hip-3-pusher/src/price_state.py rename to apps/hip-3-pusher/src/pusher/price_state.py index 078d5a08bd..764ff42015 100644 --- a/apps/hip-3-pusher/src/price_state.py +++ b/apps/hip-3-pusher/src/pusher/price_state.py @@ -1,7 +1,7 @@ from loguru import logger import time -from config import Config +from pusher.config import Config DEFAULT_STALE_PRICE_THRESHOLD_SECONDS = 5 diff --git a/apps/hip-3-pusher/src/publisher.py b/apps/hip-3-pusher/src/pusher/publisher.py similarity index 96% rename from apps/hip-3-pusher/src/publisher.py rename to apps/hip-3-pusher/src/pusher/publisher.py index 73a047f149..3ad7f7382a 100644 --- a/apps/hip-3-pusher/src/publisher.py +++ b/apps/hip-3-pusher/src/pusher/publisher.py @@ -1,5 +1,4 @@ import asyncio - from loguru import logger from eth_account import Account @@ -7,10 +6,10 @@ from hyperliquid.exchange import Exchange from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL -from config import Config -from kms_signer import KMSSigner -from metrics import Metrics -from price_state import PriceState +from pusher.config import Config +from pusher.kms_signer import KMSSigner +from pusher.metrics import Metrics +from pusher.price_state import PriceState class Publisher: diff --git a/apps/hip-3-pusher/tests/test_price_state.py b/apps/hip-3-pusher/tests/test_price_state.py new file mode 100644 index 0000000000..21ac0ad034 --- /dev/null +++ b/apps/hip-3-pusher/tests/test_price_state.py @@ -0,0 +1,70 @@ +import time + +from pusher.config import Config, LazerConfig, HermesConfig +from pusher.price_state import PriceState, PriceUpdate + + +def get_config(): + config: Config = Config.model_construct() + config.stale_price_threshold_seconds = 5 + config.lazer = LazerConfig.model_construct() + config.lazer.base_feed_exponent = -8 + config.lazer.quote_feed_exponent = -8 + config.hermes = HermesConfig.model_construct() + config.hermes.base_feed_exponent = -8 + config.hermes.quote_feed_exponent = -8 + return config + + +def test_good_hl_price(): + config = get_config() + price_state = PriceState(config) + now = time.time() + price_state.hl_oracle_price = PriceUpdate("110000.0", now - price_state.stale_price_threshold_seconds / 2.0) + + oracle_px = price_state.get_current_oracle_price() + assert oracle_px == price_state.hl_oracle_price.price + assert oracle_px == "110000.0" + + + +def test_fallback_lazer(): + config = get_config() + price_state = PriceState(config) + now = time.time() + price_state.hl_oracle_price = PriceUpdate("110000.0", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.lazer_base_price = PriceUpdate("11050000000000", now - price_state.stale_price_threshold_seconds / 2.0) + price_state.lazer_quote_price = PriceUpdate("99000000", now - price_state.stale_price_threshold_seconds / 2.0) + + oracle_px = price_state.get_current_oracle_price() + assert oracle_px == price_state.get_lazer_price() + assert oracle_px == "111616.16" + + + +def test_fallback_hermes(): + config = get_config() + price_state = PriceState(config) + now = time.time() + price_state.hl_oracle_price = PriceUpdate("110000.0", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.lazer_base_price = PriceUpdate("11050000000000", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.lazer_quote_price = PriceUpdate("99000000", now - price_state.stale_price_threshold_seconds / 2.0) + price_state.hermes_base_price = PriceUpdate("11100000000000", now - price_state.stale_price_threshold_seconds / 2.0) + price_state.hermes_quote_price = PriceUpdate("98000000", now - price_state.stale_price_threshold_seconds / 2.0) + + oracle_px = price_state.get_current_oracle_price() + assert oracle_px == price_state.get_hermes_price() + assert oracle_px == "113265.31" + + +def test_all_fail(): + config = get_config() + price_state = PriceState(config) + now = time.time() + price_state.hl_oracle_price = PriceUpdate("110000.0", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.hl_oracle_price = PriceUpdate("110000.0", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.lazer_base_price = PriceUpdate("11050000000000", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.lazer_quote_price = PriceUpdate("99000000", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.hermes_base_price = PriceUpdate("11100000000000", now - price_state.stale_price_threshold_seconds - 1.0) + price_state.hermes_quote_price = PriceUpdate("98000000", now - price_state.stale_price_threshold_seconds - 1.0) + assert price_state.get_current_oracle_price() is None diff --git a/apps/hip-3-pusher/uv.lock b/apps/hip-3-pusher/uv.lock index dea3972cd8..46da002e3b 100644 --- a/apps/hip-3-pusher/uv.lock +++ b/apps/hip-3-pusher/uv.lock @@ -35,30 +35,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.40.36" +version = "1.40.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/21/7bc857b155e8264c92b6fa8e0860a67dc01a19cbe6ba4342500299f2ae5b/boto3-1.40.36.tar.gz", hash = "sha256:bfc1f3d5c4f5d12b8458406b8972f8794ac57e2da1ee441469e143bc0440a5c3", size = 111552, upload-time = "2025-09-22T19:26:17.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/c7/1442380ad7e211089a3c94b758ffb01079eab0183700fba9d5be417b5cb4/boto3-1.40.38.tar.gz", hash = "sha256:932ebdd8dbf8ab5694d233df86d5d0950291e0b146c27cb46da8adb4f00f6ca4", size = 111559, upload-time = "2025-09-24T19:23:25.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/4c/428b728d5cf9003f83f735d10dd522945ab20c7d67e6c987909f29be12a0/boto3-1.40.36-py3-none-any.whl", hash = "sha256:d7c1fe033f491f560cd26022a9dcf28baf877ae854f33bc64fffd0df3b9c98be", size = 139345, upload-time = "2025-09-22T19:26:15.194Z" }, + { url = "https://files.pythonhosted.org/packages/06/a9/e7e5fe3fec60fb87bc9f8b3874c4c606e290a64b2ae8c157e08c3e69d755/boto3-1.40.38-py3-none-any.whl", hash = "sha256:fac337b4f0615e4d6ceee44686e662f51d8e57916ed2bc763468e3e8c611a658", size = 139345, upload-time = "2025-09-24T19:23:23.756Z" }, ] [[package]] name = "botocore" -version = "1.40.36" +version = "1.40.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/30/75fdc75933d3bc1c8dd7fbaee771438328b518936906b411075b1eacac93/botocore-1.40.36.tar.gz", hash = "sha256:93386a8dc54173267ddfc6cd8636c9171e021f7c032aa1df3af7de816e3df616", size = 14349583, upload-time = "2025-09-22T19:26:05.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/11/82a216e24f1af1ba5c3c358201fb9eba5e502242f504dd1f42eb18cbf2c5/botocore-1.40.38.tar.gz", hash = "sha256:18039009e1eca2bff12e576e8dd3c80cd9b312294f1469c831de03169582ad59", size = 14354395, upload-time = "2025-09-24T19:23:14.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/51/95c0324ac20b5bbafad4c89dd610c8e0dd6cbadbb2c8ca66dc95ccde98b8/botocore-1.40.36-py3-none-any.whl", hash = "sha256:d6edf75875e4013cb7078875a1d6c289afb4cc6675d99d80700c692d8d8e0b72", size = 14020478, upload-time = "2025-09-22T19:26:02.054Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f0/ca5a00dd8fe3768ecff54756457dd0c69ed8e1cd09d0f7c21599477b5d5b/botocore-1.40.38-py3-none-any.whl", hash = "sha256:7d60a7557db3a58f9394e7ecec1f6b87495ce947eb713f29d53aee83a6e9dc71", size = 14025193, upload-time = "2025-09-24T19:23:11.093Z" }, ] [[package]] @@ -339,21 +339,31 @@ dependencies = [ { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-sdk" }, { name = "prometheus-client" }, + { name = "tenacity" }, { name = "websockets" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ - { name = "boto3", specifier = "~=1.40.34" }, + { name = "boto3", specifier = "~=1.40.38" }, { name = "cryptography", specifier = "~=46.0.1" }, { name = "hyperliquid-python-sdk", specifier = "~=0.19.0" }, { name = "loguru", specifier = "~=0.7.3" }, { name = "opentelemetry-exporter-prometheus", specifier = "~=0.58b0" }, { name = "opentelemetry-sdk", specifier = "~=1.37.0" }, { name = "prometheus-client", specifier = "~=0.23.1" }, + { name = "tenacity", specifier = "~=9.1.2" }, { name = "websockets", specifier = "~=15.0.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = "~=8.4.2" }] + [[package]] name = "hyperliquid-python-sdk" version = "0.19.0" @@ -391,6 +401,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] +[[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 = "jmespath" version = "1.0.1" @@ -485,6 +504,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, ] +[[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 = "parsimonious" version = "0.10.0" @@ -497,6 +525,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, ] +[[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 = "prometheus-client" version = "0.23.1" @@ -588,6 +625,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[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 = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "python-dateutil" version = "2.9.0.post0" @@ -670,6 +732,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + [[package]] name = "toolz" version = "1.0.0"