diff --git a/apps/hip-3-pusher/config/config.toml b/apps/hip-3-pusher/config/config.toml index db4380ffee..845e3577d2 100644 --- a/apps/hip-3-pusher/config/config.toml +++ b/apps/hip-3-pusher/config/config.toml @@ -8,14 +8,12 @@ market_symbol = "BTC" use_testnet = false oracle_pusher_key_path = "/path/to/oracle_pusher_key.txt" publish_interval = 3.0 +publish_timeout = 5.0 enable_publish = false [kms] enable_kms = false -key_path = "/path/to/aws_kms_key_id.txt" -access_key_id_path = "/path/to/aws_access_key_id.txt" -secret_access_key_path = "/path/to/aws_secret_access_key.txt" -aws_region_name = "ap-northeast-1" +aws_kms_key_id_path = "/path/to/aws_kms_key_id.txt" [lazer] lazer_urls = ["wss://pyth-lazer-0.dourolabs.app/v1/stream", "wss://pyth-lazer-1.dourolabs.app/v1/stream"] diff --git a/apps/hip-3-pusher/pyproject.toml b/apps/hip-3-pusher/pyproject.toml index 76e587600f..1f078f6975 100644 --- a/apps/hip-3-pusher/pyproject.toml +++ b/apps/hip-3-pusher/pyproject.toml @@ -12,10 +12,8 @@ dependencies = [ "opentelemetry-exporter-prometheus~=0.58b0", "opentelemetry-sdk~=1.37.0", "prometheus-client~=0.23.1", - "setuptools~=80.9", "tenacity~=9.1.2", "websockets~=15.0.1", - "wheel~=0.45.1", ] [build-system] diff --git a/apps/hip-3-pusher/src/pusher/config.py b/apps/hip-3-pusher/src/pusher/config.py index 4eeabe37c7..6e76cf5840 100644 --- a/apps/hip-3-pusher/src/pusher/config.py +++ b/apps/hip-3-pusher/src/pusher/config.py @@ -1,14 +1,13 @@ -from pydantic import BaseModel +from hyperliquid.utils.constants import MAINNET_API_URL, TESTNET_API_URL +from pydantic import BaseModel, FilePath, model_validator +from typing import Optional STALE_TIMEOUT_SECONDS = 5 class KMSConfig(BaseModel): enable_kms: bool - aws_region_name: str - key_path: str - access_key_id_path: str - secret_access_key_path: str + aws_kms_key_id_path: FilePath class LazerConfig(BaseModel): @@ -30,13 +29,21 @@ class HermesConfig(BaseModel): class HyperliquidConfig(BaseModel): hyperliquid_ws_urls: list[str] + push_urls: Optional[list[str]] = None market_name: str market_symbol: str use_testnet: bool - oracle_pusher_key_path: str + oracle_pusher_key_path: FilePath publish_interval: float + publish_timeout: float enable_publish: bool + @model_validator(mode="after") + def set_default_urls(self): + if self.push_urls is None: + self.push_urls = [TESTNET_API_URL] if self.use_testnet else [MAINNET_API_URL] + return self + class Config(BaseModel): stale_price_threshold_seconds: int diff --git a/apps/hip-3-pusher/src/pusher/exception.py b/apps/hip-3-pusher/src/pusher/exception.py index 7dd48d5555..04c8a4c58f 100644 --- a/apps/hip-3-pusher/src/pusher/exception.py +++ b/apps/hip-3-pusher/src/pusher/exception.py @@ -1,2 +1,6 @@ -class StaleConnection(Exception): +class StaleConnectionError(Exception): + pass + + +class PushError(Exception): pass diff --git a/apps/hip-3-pusher/src/pusher/hermes_listener.py b/apps/hip-3-pusher/src/pusher/hermes_listener.py index f9811fa4e4..cac0768e1c 100644 --- a/apps/hip-3-pusher/src/pusher/hermes_listener.py +++ b/apps/hip-3-pusher/src/pusher/hermes_listener.py @@ -6,7 +6,7 @@ from tenacity import retry, retry_if_exception_type, wait_exponential from pusher.config import Config, STALE_TIMEOUT_SECONDS -from pusher.exception import StaleConnection +from pusher.exception import StaleConnectionError from pusher.price_state import PriceState, PriceUpdate @@ -34,7 +34,7 @@ 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)), + retry=retry_if_exception_type((StaleConnectionError, websockets.ConnectionClosed)), wait=wait_exponential(multiplier=1, min=1, max=10), reraise=True, ) @@ -55,7 +55,7 @@ async def subscribe_single_inner(self, url): data = json.loads(message) self.parse_hermes_message(data) except asyncio.TimeoutError: - raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") + raise StaleConnectionError(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") except websockets.ConnectionClosed: raise except json.JSONDecodeError as e: diff --git a/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py b/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py index 60f8cb117f..d0544c9168 100644 --- a/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py +++ b/apps/hip-3-pusher/src/pusher/hyperliquid_listener.py @@ -6,7 +6,7 @@ import time from pusher.config import Config, STALE_TIMEOUT_SECONDS -from pusher.exception import StaleConnection +from pusher.exception import StaleConnectionError from pusher.price_state import PriceState, PriceUpdate # This will be in config, but note here. @@ -35,7 +35,7 @@ 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)), + retry=retry_if_exception_type((StaleConnectionError, websockets.ConnectionClosed)), wait=wait_exponential(multiplier=1, min=1, max=10), reraise=True, ) @@ -65,7 +65,7 @@ async def subscribe_single_inner(self, url): else: logger.error("Received unknown channel: {}", channel) except asyncio.TimeoutError: - raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") + raise StaleConnectionError(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") except websockets.ConnectionClosed: raise except json.JSONDecodeError as e: diff --git a/apps/hip-3-pusher/src/pusher/kms_signer.py b/apps/hip-3-pusher/src/pusher/kms_signer.py index d91582c44c..d1378c4281 100644 --- a/apps/hip-3-pusher/src/pusher/kms_signer.py +++ b/apps/hip-3-pusher/src/pusher/kms_signer.py @@ -6,51 +6,61 @@ from eth_keys.datatypes import Signature from eth_utils import keccak, to_hex from hyperliquid.exchange import Exchange -from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL from hyperliquid.utils.signing import get_timestamp_ms, action_hash, construct_phantom_agent, l1_payload from loguru import logger +from pathlib import Path from pusher.config import Config +from pusher.exception import PushError SECP256K1_N_HALF = SECP256K1_N // 2 +def _init_client(): + # AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY should be set as environment variables + return boto3.client( + "kms", + # can specify an endpoint for e.g. LocalStack + # endpoint_url="http://localhost:4566" + ) + + class KMSSigner: - def __init__(self, config: Config): - use_testnet = config.hyperliquid.use_testnet - url = TESTNET_API_URL if use_testnet else MAINNET_API_URL - self.oracle_publisher_exchange: Exchange = Exchange(wallet=None, base_url=url) - self.client = self._init_client(config) + def __init__(self, config: Config, publisher_exchanges: list[Exchange]): + self.use_testnet = config.hyperliquid.use_testnet + self.publisher_exchanges = publisher_exchanges + + # AWS client and public key load + self.client = _init_client() + try: + self._load_public_key(config.kms.aws_kms_key_id_path) + except Exception as e: + logger.exception("Failed to load public key from KMS; it might be incorrectly configured; error: {}", repr(e)) + exit() + def _load_public_key(self, key_path: str): # Fetch public key once so we can derive address and check recovery id - key_path = config.kms.key_path - self.key_id = open(key_path, "r").read().strip() - self.pubkey_der = self.client.get_public_key(KeyId=self.key_id)["PublicKey"] + self.aws_kms_key_id = Path(key_path).read_text().strip() + pubkey_der = self.client.get_public_key(KeyId=self.aws_kms_key_id)["PublicKey"] + self.pubkey = serialization.load_der_public_key(pubkey_der) + self._construct_pubkey_address_and_bytes() + + def _construct_pubkey_address_and_bytes(self): # Construct eth address to log - pub = serialization.load_der_public_key(self.pubkey_der) - numbers = pub.public_numbers() + numbers = self.pubkey.public_numbers() x = numbers.x.to_bytes(32, "big") y = numbers.y.to_bytes(32, "big") uncompressed = b"\x04" + x + y - self.public_key_bytes = uncompressed self.address = "0x" + keccak(uncompressed[1:])[-20:].hex() - logger.info("KMSSigner address: {}", self.address) - - def _init_client(self, config): - aws_region_name = config.kms.aws_region_name - access_key_id_path = config.kms.access_key_id_path - access_key_id = open(access_key_id_path, "r").read().strip() - secret_access_key_path = config.kms.secret_access_key_path - secret_access_key = open(secret_access_key_path, "r").read().strip() - - return boto3.client( - "kms", - region_name=aws_region_name, - aws_access_key_id=access_key_id, - aws_secret_access_key=secret_access_key, - # can specify an endpoint for e.g. LocalStack - # endpoint_url="http://localhost:4566" + logger.info("public key loaded from KMS: {}", self.address) + + # Parse KMS public key into uncompressed secp256k1 bytes + pubkey_bytes = self.pubkey.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, ) + # Strip leading 0x04 (uncompressed point indicator) + self.raw_pubkey_bytes = pubkey_bytes[1:] def set_oracle(self, dex, oracle_pxs, all_mark_pxs, external_perp_pxs): timestamp = get_timestamp_ms() @@ -67,15 +77,24 @@ def set_oracle(self, dex, oracle_pxs, all_mark_pxs, external_perp_pxs): }, } signature = self.sign_l1_action( - action, - timestamp, - self.oracle_publisher_exchange.base_url == MAINNET_API_URL, - ) - return self.oracle_publisher_exchange._post_action( - action, - signature, - timestamp, + action=action, + nonce=timestamp, + is_mainnet=not self.use_testnet, ) + return self._send_update(action, signature, timestamp) + + def _send_update(self, action, signature, timestamp): + for exchange in self.publisher_exchanges: + try: + return exchange._post_action( + action=action, + signature=signature, + nonce=timestamp, + ) + except Exception as e: + logger.exception("perp_deploy_set_oracle exception for endpoint: {} error: {}", exchange.base_url, repr(e)) + + raise PushError("all push endpoints failed") def sign_l1_action(self, action, nonce, is_mainnet): hash = action_hash(action, vault_address=None, nonce=nonce, expires_after=None) @@ -88,7 +107,7 @@ def sign_l1_action(self, action, nonce, is_mainnet): def sign_message(self, message_hash: bytes) -> dict: # Send message hash to KMS for signing resp = self.client.sign( - KeyId=self.key_id, + KeyId=self.aws_kms_key_id, Message=message_hash, MessageType="DIGEST", SigningAlgorithm="ECDSA_SHA_256", # required for secp256k1 @@ -99,20 +118,12 @@ def sign_message(self, message_hash: bytes) -> dict: # Ethereum requires low-s form if s > SECP256K1_N_HALF: s = SECP256K1_N - s - # Parse KMS public key into uncompressed secp256k1 bytes - # TODO: Pull this into init - pubkey = serialization.load_der_public_key(self.pubkey_der) - pubkey_bytes = pubkey.public_bytes( - serialization.Encoding.X962, - serialization.PublicFormat.UncompressedPoint, - ) - # Strip leading 0x04 (uncompressed point indicator) - raw_pubkey_bytes = pubkey_bytes[1:] + # Try both recovery ids for v in (0, 1): sig_obj = Signature(vrs=(v, r, s)) recovered_pub = sig_obj.recover_public_key_from_msg_hash(message_hash) - if recovered_pub.to_bytes() == raw_pubkey_bytes: + if recovered_pub.to_bytes() == self.raw_pubkey_bytes: return { "r": to_hex(r), "s": to_hex(s), diff --git a/apps/hip-3-pusher/src/pusher/lazer_listener.py b/apps/hip-3-pusher/src/pusher/lazer_listener.py index 2e5adca52e..4bca7b2e45 100644 --- a/apps/hip-3-pusher/src/pusher/lazer_listener.py +++ b/apps/hip-3-pusher/src/pusher/lazer_listener.py @@ -6,7 +6,7 @@ from tenacity import retry, retry_if_exception_type, wait_exponential from pusher.config import Config, STALE_TIMEOUT_SECONDS -from pusher.exception import StaleConnection +from pusher.exception import StaleConnectionError from pusher.price_state import PriceState, PriceUpdate @@ -38,7 +38,7 @@ 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)), + retry=retry_if_exception_type((StaleConnectionError, websockets.ConnectionClosed)), wait=wait_exponential(multiplier=1, min=1, max=10), reraise=True, ) @@ -63,7 +63,7 @@ async def subscribe_single_inner(self, router_url): data = json.loads(message) self.parse_lazer_message(data) except asyncio.TimeoutError: - raise StaleConnection(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") + raise StaleConnectionError(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting") except websockets.ConnectionClosed: raise except json.JSONDecodeError as e: diff --git a/apps/hip-3-pusher/src/pusher/main.py b/apps/hip-3-pusher/src/pusher/main.py index 837d63bccc..558053c8c1 100644 --- a/apps/hip-3-pusher/src/pusher/main.py +++ b/apps/hip-3-pusher/src/pusher/main.py @@ -62,4 +62,4 @@ async def main(): try: asyncio.run(main()) except Exception as e: - logger.exception("Uncaught exception, exiting: {}", e) + logger.exception("Uncaught exception, exiting; error: {}", repr(e)) diff --git a/apps/hip-3-pusher/src/pusher/metrics.py b/apps/hip-3-pusher/src/pusher/metrics.py index 22338649e9..857b555a59 100644 --- a/apps/hip-3-pusher/src/pusher/metrics.py +++ b/apps/hip-3-pusher/src/pusher/metrics.py @@ -17,9 +17,7 @@ def __init__(self, config: Config): reader = PrometheusMetricReader() # Meter is responsible for creating and recording metrics set_meter_provider(MeterProvider(metric_readers=[reader])) - # TODO: sync version number and add? self.meter = get_meter_provider().get_meter(METER_NAME) - self._init_metrics() def _init_metrics(self): @@ -35,5 +33,8 @@ def _init_metrics(self): name="hip_3_pusher_failed_push_count", description="Number of failed push attempts", ) - - # TODO: labels/attributes + self.push_interval_histogram = self.meter.create_histogram( + name="hip_3_pusher_push_interval", + description="Interval between push requests (seconds)", + unit="s", + ) diff --git a/apps/hip-3-pusher/src/pusher/publisher.py b/apps/hip-3-pusher/src/pusher/publisher.py index 3ad7f7382a..0802b74a7d 100644 --- a/apps/hip-3-pusher/src/pusher/publisher.py +++ b/apps/hip-3-pusher/src/pusher/publisher.py @@ -1,12 +1,15 @@ import asyncio +import time + from loguru import logger +from pathlib import Path from eth_account import Account from eth_account.signers.local import LocalAccount from hyperliquid.exchange import Exchange -from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL from pusher.config import Config +from pusher.exception import PushError from pusher.kms_signer import KMSSigner from pusher.metrics import Metrics from pusher.price_state import PriceState @@ -20,28 +23,33 @@ class Publisher: """ def __init__(self, config: Config, price_state: PriceState, metrics: Metrics): self.publish_interval = float(config.hyperliquid.publish_interval) - self.kms_signer = None - self.enable_kms = False self.use_testnet = config.hyperliquid.use_testnet + self.push_urls = config.hyperliquid.push_urls + logger.info("push urls: {}", self.push_urls) - if config.kms.enable_kms: - self.enable_kms = True - oracle_account = None - self.kms_signer = KMSSigner(config) - else: - oracle_pusher_key_path = config.hyperliquid.oracle_pusher_key_path - oracle_pusher_key = open(oracle_pusher_key_path, "r").read().strip() + self.kms_signer = None + self.enable_kms = False + oracle_account = None + if not config.kms.enable_kms: + oracle_pusher_key = Path(config.hyperliquid.oracle_pusher_key_path).read_text().strip() oracle_account: LocalAccount = Account.from_key(oracle_pusher_key) logger.info("oracle pusher local pubkey: {}", oracle_account.address) + self.publisher_exchanges = [Exchange(wallet=oracle_account, + base_url=url, + timeout=config.hyperliquid.publish_timeout) + for url in self.push_urls] + if config.kms.enable_kms: + self.enable_kms = True + self.kms_signer = KMSSigner(config, self.publisher_exchanges) - url = TESTNET_API_URL if self.use_testnet else MAINNET_API_URL - self.oracle_publisher_exchange: Exchange = Exchange(wallet=oracle_account, base_url=url) self.market_name = config.hyperliquid.market_name self.market_symbol = config.hyperliquid.market_symbol self.enable_publish = config.hyperliquid.enable_publish self.price_state = price_state self.metrics = metrics + self.metrics_labels = {"dex": self.market_name} + self.last_push_time = time.time() async def run(self): while True: @@ -49,48 +57,79 @@ async def run(self): try: self.publish() except Exception as e: - logger.exception("Publisher.publish() exception: {}", e) + logger.exception("Publisher.publish() exception: {}", repr(e)) def publish(self): oracle_pxs = {} oracle_px = self.price_state.get_current_oracle_price() if not oracle_px: logger.error("No valid oracle price available") - self.metrics.no_oracle_price_counter.add(1) + self.metrics.no_oracle_price_counter.add(1, self.metrics_labels) return else: logger.debug("Current oracle price: {}", oracle_px) oracle_pxs[self.market_symbol] = oracle_px mark_pxs = [] - #if self.price_state.hl_mark_price: - # mark_pxs.append({self.market_symbol: self.price_state.hl_mark_price}) - external_perp_pxs = {} + if self.price_state.hl_mark_price: + external_perp_pxs[self.market_symbol] = self.price_state.hl_mark_price.price + # TODO: "Each update can change oraclePx and markPx by at most 1%." # TODO: "The markPx cannot be updated such that open interest would be 10x the open interest cap." if self.enable_publish: - if self.enable_kms: - push_response = self.kms_signer.set_oracle( - dex=self.market_name, - oracle_pxs=oracle_pxs, - all_mark_pxs=mark_pxs, - external_perp_pxs=external_perp_pxs, - ) - else: - push_response = self.oracle_publisher_exchange.perp_deploy_set_oracle( + try: + if self.enable_kms: + push_response = self.kms_signer.set_oracle( + dex=self.market_name, + oracle_pxs=oracle_pxs, + all_mark_pxs=mark_pxs, + external_perp_pxs=external_perp_pxs, + ) + else: + push_response = self._send_update( + oracle_pxs=oracle_pxs, + all_mark_pxs=mark_pxs, + external_perp_pxs=external_perp_pxs, + ) + self._handle_response(push_response) + except PushError: + logger.error("All push attempts failed") + self.metrics.failed_push_counter.add(1, self.metrics_labels) + except Exception as e: + logger.exception("Unexpected exception in push request: {}", repr(e)) + else: + logger.debug("push disabled") + + self._record_push_interval_metric() + + def _send_update(self, oracle_pxs, all_mark_pxs, external_perp_pxs): + for exchange in self.publisher_exchanges: + try: + return exchange.perp_deploy_set_oracle( dex=self.market_name, oracle_pxs=oracle_pxs, - all_mark_pxs=mark_pxs, + all_mark_pxs=all_mark_pxs, external_perp_pxs=external_perp_pxs, ) + except Exception as e: + logger.exception("perp_deploy_set_oracle exception for endpoint: {} error: {}", exchange.base_url, repr(e)) + + raise PushError("all push endpoints failed") + + def _handle_response(self, response): + logger.debug("publish: push response: {} {}", response, type(response)) + status = response.get("status") + if status == "ok": + self.metrics.successful_push_counter.add(1, self.metrics_labels) + elif status == "err": + self.metrics.failed_push_counter.add(1, self.metrics_labels) + logger.error("publish: publish error: {}", response) - # TODO: Look at specific error responses and log/alert accordingly - logger.debug("publish: push response: {} {}", push_response, type(push_response)) - status = push_response.get("status", "") - if status == "ok": - self.metrics.successful_push_counter.add(1) - elif status == "err": - self.metrics.failed_push_counter.add(1) - logger.error("publish: publish error: {}", push_response) + def _record_push_interval_metric(self): + now = time.time() + push_interval = now - self.last_push_time + self.metrics.push_interval_histogram.record(push_interval, self.metrics_labels) + self.last_push_time = now + logger.debug("Push interval: {}", push_interval) diff --git a/apps/hip-3-pusher/uv.lock b/apps/hip-3-pusher/uv.lock index 497dac1534..00762dd521 100644 --- a/apps/hip-3-pusher/uv.lock +++ b/apps/hip-3-pusher/uv.lock @@ -339,10 +339,8 @@ dependencies = [ { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-sdk" }, { name = "prometheus-client" }, - { name = "setuptools" }, { name = "tenacity" }, { name = "websockets" }, - { name = "wheel" }, ] [package.dev-dependencies] @@ -359,10 +357,8 @@ requires-dist = [ { name = "opentelemetry-exporter-prometheus", specifier = "~=0.58b0" }, { name = "opentelemetry-sdk", specifier = "~=1.37.0" }, { name = "prometheus-client", specifier = "~=0.23.1" }, - { name = "setuptools", specifier = "~=80.9" }, { name = "tenacity", specifier = "~=9.1.2" }, { name = "websockets", specifier = "~=15.0.1" }, - { name = "wheel", specifier = "~=0.45.1" }, ] [package.metadata.requires-dev] @@ -727,15 +723,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -822,15 +809,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] -[[package]] -name = "wheel" -version = "0.45.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, -] - [[package]] name = "win32-setctime" version = "1.2.0"