Forráskód Böngészése

feat(hip-3-pusher): AWS client config, backup write support, timeout/metric fixes

Mike Rolish 1 hónapja
szülő
commit
75f2e249f1

+ 2 - 4
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"]

+ 0 - 2
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]

+ 13 - 6
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

+ 5 - 1
apps/hip-3-pusher/src/pusher/exception.py

@@ -1,2 +1,6 @@
-class StaleConnection(Exception):
+class StaleConnectionError(Exception):
+    pass
+
+
+class PushError(Exception):
     pass

+ 3 - 3
apps/hip-3-pusher/src/pusher/hermes_listener.py

@@ -6,7 +6,7 @@ import websockets
 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 @@ class HermesListener:
         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 @@ class HermesListener:
                     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:

+ 3 - 3
apps/hip-3-pusher/src/pusher/hyperliquid_listener.py

@@ -6,7 +6,7 @@ from tenacity import retry, retry_if_exception_type, wait_exponential
 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 @@ class HyperliquidListener:
         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 @@ class HyperliquidListener:
                     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:

+ 58 - 47
apps/hip-3-pusher/src/pusher/kms_signer.py

@@ -6,51 +6,61 @@ from eth_keys.backends.native.ecdsa import N as SECP256K1_N
 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 @@ class KMSSigner:
             },
         }
         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 @@ class KMSSigner:
     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 @@ class KMSSigner:
         # 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),

+ 3 - 3
apps/hip-3-pusher/src/pusher/lazer_listener.py

@@ -6,7 +6,7 @@ import websockets
 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 @@ class LazerListener:
         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 @@ class LazerListener:
                     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:

+ 1 - 1
apps/hip-3-pusher/src/pusher/main.py

@@ -62,4 +62,4 @@ if __name__ == "__main__":
     try:
         asyncio.run(main())
     except Exception as e:
-        logger.exception("Uncaught exception, exiting: {}", e)
+        logger.exception("Uncaught exception, exiting; error: {}", repr(e))

+ 5 - 4
apps/hip-3-pusher/src/pusher/metrics.py

@@ -17,9 +17,7 @@ class Metrics:
         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 @@ class Metrics:
             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",
+        )

+ 74 - 35
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 @@ class Publisher:
             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)

+ 0 - 22
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"