From 2b7db521c4edffe4e4d3acdd21ba02e74bab598b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 27 Jul 2026 15:43:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20OTel=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=20+=20=E9=95=9C=E5=83=8F=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=90=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。 接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span), 解析失败时把页面 HTML 作为 span event 上报,便于事后复现。 Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。 Co-Authored-By: Claude Opus 4.6 --- .env.example | 13 + Dockerfile | 12 +- app/scraping/main.py | 6 + app/scraping/services/rakuma_client.py | 172 +++++++---- app/scraping/services/rakuma_session.py | 98 +++--- app/scraping/services/rakuten_client.py | 192 ++++++++---- app/scraping/services/site_session.py | 126 +++++--- app/shared/config.py | 12 + app/shared/telemetry.py | 145 +++++++++ app/trading/main.py | 5 + pyproject.toml | 5 + tests/test_telemetry.py | 48 +++ uv.lock | 390 ++++++++++++++++++++++++ 13 files changed, 1021 insertions(+), 203 deletions(-) create mode 100644 app/shared/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/.env.example b/.env.example index 82d40ae..83ce9d7 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,16 @@ RAKUTEN_AUTH_STATE_DIR=.auth # 下单金额上限(日元):实际应付超过该值直接拒绝提交,防止解析出错或页面改版 # 导致买到远超预期的订单。设为 0 表示不设上限(不建议)。 RAKUTEN_ORDER_MAX_TOTAL_YEN=30000 + +# ---- OpenTelemetry traces(可选;默认关闭)---- +# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint, +# 用于排查"抓到的内容为什么解析不出预期字段"。 +# 解析失败时会把页面 HTML 作为 span event 上报(上限可配),便于事后复现。 +RAKUTEN_OTEL_ENABLED=false +RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces +# 服务名按服务覆盖;不设时两侧 main.py 分别按 rakuten-scraping / rakuten-trading 落地 +RAKUTEN_OTEL_SERVICE_NAME= +# OTLP 鉴权头(形如 key=val,key=val);当前 endpoint 裸跑,留空 +RAKUTEN_OTEL_HEADERS= +# 失败 HTML 快照上限字节(默认 2MB,超出截断并标注 truncated=true) +RAKUTEN_OTEL_SNAPSHOT_MAX_BYTES=2000000 diff --git a/Dockerfile b/Dockerfile index 0545109..141864d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,11 @@ # 镜像默认包含 Playwright 浏览器兜底(与 .env.example 的 # RAKUTEN_BROWSER_FALLBACK_ENABLED=true 对齐),首次构建多约 400MB。 # 若要裁剪,可改为 uv sync --no-dev(去掉 --extra browser)。 +# +# 镜像默认开启 OpenTelemetry traces:RAKUTEN_OTEL_ENABLED=true + +# RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces 写入 ENV。 +# 容器一启动就把抓取-解析链路导出到自建 OTLP 后端,便于排查"抓回的内容 +# 为什么解析不出预期字段"。endpoint 裸跑(无鉴权);需要时运行时覆盖。 # ----------------------------- builder ----------------------------- FROM registry.jerryyan.top/library/python:3.13-slim AS builder @@ -46,7 +51,12 @@ ENV PYTHONUNBUFFERED=1 \ # Playwright 浏览器二进制走 npmmirror 镜像,避免从 azureedge 拉取缓慢 PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \ RAKUTEN_APP_HOST=0.0.0.0 \ - RAKUTEN_APP_PORT=31107 + RAKUTEN_APP_PORT=31107 \ + # OpenTelemetry traces 默认开启:镜像一启动就把抓取-解析链路导出到自建 OTLP endpoint。 + # endpoint 裸跑(无鉴权);如部署到需要鉴权的网关,运行时覆盖 RAKUTEN_OTEL_HEADERS 即可。 + # 服务名(rakuten-scraping / rakuten-trading)由两侧 main.py 显式设定,此处不覆盖。 + RAKUTEN_OTEL_ENABLED=true \ + RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces # Chromium 运行所需的最小系统依赖。 # 这里显式列出而不是用 `playwright install-deps`,是因为后者要求 root + apt diff --git a/app/scraping/main.py b/app/scraping/main.py index e03c022..bbef411 100644 --- a/app/scraping/main.py +++ b/app/scraping/main.py @@ -27,6 +27,7 @@ from app.scraping.services.site_session import SiteSession from app.shared.api import register_exception_handlers from app.shared.config import get_settings from app.shared.logging_setup import configure_logging +from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry logger = logging.getLogger(__name__) @@ -56,6 +57,9 @@ async def lifespan(app: FastAPI): app.state.container = container configure_logging(container.settings) + # telemetry 必须在创建任何 httpx.AsyncClient 之前完成 setup,否则 httpx + # 自动 instrumentation 抓不到现有客户端的请求。 + setup_telemetry(container.settings, service_name="rakuten-scraping") logger.info("抓取服务启动:%s:%s", container.settings.app_host, container.settings.app_port) logger.info("日志级别:%s", container.settings.log_level) logger.info("当前环境:%s", container.settings.app_env) @@ -67,6 +71,7 @@ async def lifespan(app: FastAPI): await container.rakuma_session.close() await container.site_session.close() await container.browser_fallback.close() + shutdown_telemetry() def create_app() -> FastAPI: @@ -76,6 +81,7 @@ def create_app() -> FastAPI: app.include_router(scrape_router) app.include_router(rakuma_router) register_exception_handlers(app) + instrument_app(app) return app diff --git a/app/scraping/services/rakuma_client.py b/app/scraping/services/rakuma_client.py index 9d0bae0..e3bb2f9 100644 --- a/app/scraping/services/rakuma_client.py +++ b/app/scraping/services/rakuma_client.py @@ -8,6 +8,8 @@ from __future__ import annotations import asyncio import logging +from opentelemetry import trace + from app.scraping.core import rakuma_site as site from app.shared.config import Settings from app.scraping.models.scrape import ( @@ -35,8 +37,10 @@ from app.scraping.utils.rakuma_urls import ( split_item_url, split_shop_url, ) +from app.shared.telemetry import snapshot logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) class RakumaClient: @@ -54,18 +58,30 @@ class RakumaClient: url = build_search_url(payload) logger.info("抓取 ラクマ 搜索页:url=%s", url) - html = await self._session.fetch_html(url) - result = parse_search( - html, - request_url=url, - page=payload.page, - keyword=payload.keyword.strip(), - ) - logger.info( - "ラクマ 搜索完成:url=%s items=%s total=%s", - url, len(result.items), result.total_count, - ) - return result + html: str | None = None + with tracer.start_as_current_span("parse.rakuma.search") as span: + span.set_attribute("parse.site", "rakuma") + span.set_attribute("parse.op", "search") + try: + html = await self._session.fetch_html(url) + result = parse_search( + html, + request_url=url, + page=payload.page, + keyword=payload.keyword.strip(), + ) + span.set_attribute("parse.items", len(result.items)) + span.set_attribute("parse.total", result.total_count) + logger.info( + "ラクマ 搜索完成:url=%s items=%s total=%s", + url, len(result.items), result.total_count, + ) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def categories(self, payload: RakumaCategoryRequest) -> RakumaCategoryData: """抓取分类树 @@ -76,17 +92,29 @@ class RakumaClient: url = site.CATEGORY_LIST_URL logger.info("抓取 ラクマ 分类页:category_id=%s", payload.category_id) - html = await self._session.fetch_html(url) - data = parse_categories( - html, - category_id=payload.category_id, - include_descendants=payload.include_descendants, - ) - logger.info( - "ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s", - data.category_id, data.name, len(data.children), data.total_count, - ) - return data + html: str | None = None + with tracer.start_as_current_span("parse.rakuma.categories") as span: + span.set_attribute("parse.site", "rakuma") + span.set_attribute("parse.op", "categories") + try: + html = await self._session.fetch_html(url) + data = parse_categories( + html, + category_id=payload.category_id, + include_descendants=payload.include_descendants, + ) + span.set_attribute("parse.children", len(data.children)) + span.set_attribute("parse.tree", data.total_count) + logger.info( + "ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s", + data.category_id, data.name, len(data.children), data.total_count, + ) + return data + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def item_detail(self, payload: RakumaItemDetailRequest) -> RakumaItemDetailData: """抓取商品详情""" @@ -97,13 +125,25 @@ class RakumaClient: url = build_item_url(item_id) logger.info("抓取 ラクマ 商品详情:url=%s", url) - html = await self._session.fetch_html(url) - detail = parse_item_detail(html, item_id=item_id, item_url=url) - logger.info( - "ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s", - url, detail.item_name[:40], detail.price, detail.is_sold_out, - ) - return detail + html: str | None = None + with tracer.start_as_current_span("parse.rakuma.item_detail") as span: + span.set_attribute("parse.site", "rakuma") + span.set_attribute("parse.op", "item_detail") + try: + html = await self._session.fetch_html(url) + detail = parse_item_detail(html, item_id=item_id, item_url=url) + span.set_attribute("parse.price", detail.price) + span.set_attribute("parse.sold_out", detail.is_sold_out) + logger.info( + "ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s", + url, detail.item_name[:40], detail.price, detail.is_sold_out, + ) + return detail + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def shop_detail(self, payload: RakumaShopDetailRequest) -> RakumaShopDetailData: """抓取卖家详情 @@ -114,22 +154,34 @@ class RakumaClient: url = build_shop_url(shop_id) logger.info("抓取 ラクマ 卖家详情:url=%s reviews=%s", url, payload.include_reviews) - if payload.include_reviews: - html, review_html = await asyncio.gather( - self._session.fetch_html(url), - self._session.fetch_html(build_shop_url(shop_id, review=True)), - ) - else: - html, review_html = await self._session.fetch_html(url), None + html: str | None = None + with tracer.start_as_current_span("parse.rakuma.shop_detail") as span: + span.set_attribute("parse.site", "rakuma") + span.set_attribute("parse.op", "shop_detail") + try: + if payload.include_reviews: + html, review_html = await asyncio.gather( + self._session.fetch_html(url), + self._session.fetch_html(build_shop_url(shop_id, review=True)), + ) + else: + html, review_html = await self._session.fetch_html(url), None - detail = parse_shop_detail( - html, shop_id=shop_id, shop_url=url, review_html=review_html - ) - logger.info( - "ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s", - shop_id, detail.shop_name, detail.item_count, detail.review_count, - ) - return detail + detail = parse_shop_detail( + html, shop_id=shop_id, shop_url=url, review_html=review_html + ) + span.set_attribute("parse.items", detail.item_count) + span.set_attribute("parse.reviews", detail.review_count) + logger.info( + "ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s", + shop_id, detail.shop_name, detail.item_count, detail.review_count, + ) + return detail + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def shop_items(self, payload: RakumaShopItemsRequest) -> RakumaShopItemsData: """抓取卖家的商品列表""" @@ -137,15 +189,27 @@ class RakumaClient: url = build_shop_url(shop_id, page=payload.page) logger.info("抓取 ラクマ 卖家商品:url=%s", url) - html = await self._session.fetch_html(url) - result = parse_shop_items( - html, shop_id=shop_id, request_url=url, page=payload.page - ) - logger.info( - "ラクマ 卖家商品完成:shop_id=%s items=%s total=%s", - shop_id, len(result.items), result.total_count, - ) - return result + html: str | None = None + with tracer.start_as_current_span("parse.rakuma.shop_items") as span: + span.set_attribute("parse.site", "rakuma") + span.set_attribute("parse.op", "shop_items") + try: + html = await self._session.fetch_html(url) + result = parse_shop_items( + html, shop_id=shop_id, request_url=url, page=payload.page + ) + span.set_attribute("parse.items", len(result.items)) + span.set_attribute("parse.total", result.total_count) + logger.info( + "ラクマ 卖家商品完成:shop_id=%s items=%s total=%s", + shop_id, len(result.items), result.total_count, + ) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise @staticmethod def _resolve_shop_id(shop_url: object, shop_id: str | None) -> str: diff --git a/app/scraping/services/rakuma_session.py b/app/scraping/services/rakuma_session.py index 3cf7dee..9619bcc 100644 --- a/app/scraping/services/rakuma_session.py +++ b/app/scraping/services/rakuma_session.py @@ -18,6 +18,7 @@ import logging from typing import Any import httpx +from opentelemetry import trace from app.scraping.core import rakuma_site as site from app.shared.config import Settings @@ -27,6 +28,7 @@ from app.shared.errors import ( UpstreamBlockedError, UpstreamRequestError, ) +from app.shared.telemetry import snapshot logger = logging.getLogger(__name__) @@ -89,47 +91,71 @@ class RakumaSession: if client is None: raise UpstreamRequestError("ラクマ 会话尚未初始化") + tracer = trace.get_tracer(__name__) max_attempts = max(1, self._settings.http_max_attempts) last_error = "unknown error" + last_text: str | None = None - try: - await asyncio.wait_for( - self._semaphore.acquire(), - timeout=self._settings.request_timeout_seconds, - ) - except TimeoutError as exc: - raise ResourceBusyError() from exc + with tracer.start_as_current_span("scrape.fetch") as span: + span.set_attribute("scrape.url", url) + span.set_attribute("scrape.profile", "rakuma") - try: - for attempt in range(1, max_attempts + 1): - try: - response = await client.get(url) - except httpx.HTTPError as exc: - last_error = f"{type(exc).__name__}: {exc}" + try: + await asyncio.wait_for( + self._semaphore.acquire(), + timeout=self._settings.request_timeout_seconds, + ) + except TimeoutError as exc: + err = ResourceBusyError() + span.record_exception(err) + span.set_attribute("scrape.fail_reason", "ResourceBusyError") + raise err from exc + + try: + for attempt in range(1, max_attempts + 1): + span.set_attribute("scrape.attempts", attempt) + try: + response = await client.get(url) + except httpx.HTTPError as exc: + last_error = f"{type(exc).__name__}: {exc}" + logger.warning( + "ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s", + url, attempt, max_attempts, last_error, + ) + continue + + if response.status_code == 404: + err = ItemNotFoundError(f"Page not found: {url}") + span.record_exception(err) + span.set_attribute("scrape.fail_reason", "ItemNotFoundError") + raise err + + if response.status_code < 400: + span.set_attribute("scrape.last_status_code", response.status_code) + span.set_attribute("scrape.final_url", str(response.url)) + span.set_attribute("scrape.html_bytes", len(response.text)) + return response.text + + last_error = ( + f"upstream status {response.status_code}" + if response.status_code >= 500 + else f"status {response.status_code}" + ) + last_text = response.text + span.set_attribute("scrape.last_status_code", response.status_code) + span.set_attribute("scrape.html_bytes", len(response.text)) logger.warning( - "ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s", + "ラクマ 抓取结果异常:url=%s attempt=%s/%s %s", url, attempt, max_attempts, last_error, ) - continue - if response.status_code == 404: - raise ItemNotFoundError(f"Page not found: {url}") - - if response.status_code < 400: - return response.text - - last_error = ( - f"upstream status {response.status_code}" - if response.status_code >= 500 - else f"status {response.status_code}" - ) - logger.warning( - "ラクマ 抓取结果异常:url=%s attempt=%s/%s %s", - url, attempt, max_attempts, last_error, - ) - - if last_error.startswith("upstream status") or ":" in last_error: - raise UpstreamRequestError(f"Upstream request failed: {last_error}") - raise UpstreamBlockedError(f"Failed to fetch {url}: {last_error}") - finally: - self._semaphore.release() + if last_error.startswith("upstream status") or ":" in last_error: + err = UpstreamRequestError(f"Upstream request failed: {last_error}") + else: + err = UpstreamBlockedError(f"Failed to fetch {url}: {last_error}") + span.record_exception(err) + span.set_attribute("scrape.fail_reason", type(err).__name__) + snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes) + raise err + finally: + self._semaphore.release() diff --git a/app/scraping/services/rakuten_client.py b/app/scraping/services/rakuten_client.py index 9f2be17..cd2e6b9 100644 --- a/app/scraping/services/rakuten_client.py +++ b/app/scraping/services/rakuten_client.py @@ -8,6 +8,8 @@ from __future__ import annotations import logging +from opentelemetry import trace + from app.scraping.core import site from app.shared.config import Settings from app.shared.errors import ScrapeParseError @@ -43,8 +45,10 @@ from app.scraping.utils.urls import ( split_item_url, split_shop_url, ) +from app.shared.telemetry import snapshot logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) class RakutenClient: @@ -62,19 +66,32 @@ class RakutenClient: url = build_search_url(payload) logger.info("抓取搜索页:url=%s", url) - html = await self._session.fetch_html(url, mobile=False) - state = extract_initial_state(html) - result = parse_search( - state, - request_url=url, - page=payload.page, - exclude_ads=payload.exclude_ads, - ) - logger.info( - "搜索完成:url=%s items=%s ads=%s total=%s", - url, len(result.items), result.ad_count, result.total_count, - ) - return result + html: str | None = None + with tracer.start_as_current_span("parse.rakuten.search") as span: + span.set_attribute("parse.site", "rakuten") + span.set_attribute("parse.op", "search") + try: + html = await self._session.fetch_html(url, mobile=False) + state = extract_initial_state(html) + result = parse_search( + state, + request_url=url, + page=payload.page, + exclude_ads=payload.exclude_ads, + ) + span.set_attribute("parse.items", len(result.items)) + span.set_attribute("parse.ads", result.ad_count) + span.set_attribute("parse.total", result.total_count) + logger.info( + "搜索完成:url=%s items=%s ads=%s total=%s", + url, len(result.items), result.ad_count, result.total_count, + ) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def genres(self, payload: GenreRequest) -> GenreData: """抓取分类树:顶层分类列表,或指定分类的信息与直接子分类""" @@ -82,14 +99,25 @@ class RakutenClient: url = build_genre_url(genre_id) logger.info("抓取分类树:url=%s genre_id=%s", url, genre_id) - html = await self._session.fetch_html(url, mobile=False) - state = extract_initial_state(html) - result = parse_genres(state, genre_id=genre_id) - logger.info( - "分类树完成:genre_id=%s name=%s children=%s", - result.genre_id or "root", result.name, len(result.children), - ) - return result + html: str | None = None + with tracer.start_as_current_span("parse.rakuten.genres") as span: + span.set_attribute("parse.site", "rakuten") + span.set_attribute("parse.op", "genres") + try: + html = await self._session.fetch_html(url, mobile=False) + state = extract_initial_state(html) + result = parse_genres(state, genre_id=genre_id) + span.set_attribute("parse.children", len(result.children)) + logger.info( + "分类树完成:genre_id=%s name=%s children=%s", + result.genre_id or "root", result.name, len(result.children), + ) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def shop_detail(self, payload: ShopDetailRequest) -> ShopDetailData: """抓取商家详情""" @@ -100,14 +128,25 @@ class RakutenClient: url = build_shop_url(shop_code) logger.info("抓取店铺页:url=%s", url) - html = await self._session.fetch_html(url, mobile=False) - state = extract_initial_state(html) - result = parse_shop_detail(state, shop_code=shop_code, html=html) - logger.info( - "店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s", - result.shop_code, result.shop_id, result.shop_name, result.review_count, - ) - return result + html: str | None = None + with tracer.start_as_current_span("parse.rakuten.shop_detail") as span: + span.set_attribute("parse.site", "rakuten") + span.set_attribute("parse.op", "shop_detail") + try: + html = await self._session.fetch_html(url, mobile=False) + state = extract_initial_state(html) + result = parse_shop_detail(state, shop_code=shop_code, html=html) + span.set_attribute("parse.reviews", result.review_count) + logger.info( + "店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s", + result.shop_code, result.shop_id, result.shop_name, result.review_count, + ) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise async def shop_items(self, payload: ShopItemsRequest) -> SearchResultData: """抓取商家名下的商品列表 @@ -116,14 +155,25 @@ class RakutenClient: `sid=` 限定店铺后的产物,因此这里转成一次搜索。只给 shop_code 时 需要先取一次店铺详情换出 shop_id。 """ - shop_id = payload.shop_id - if shop_id is None: - detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code)) - if detail.shop_id is None: - raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id") - shop_id = detail.shop_id + with tracer.start_as_current_span("parse.rakuten.shop_items") as span: + span.set_attribute("parse.site", "rakuten") + span.set_attribute("parse.op", "shop_items") + try: + shop_id = payload.shop_id + if shop_id is None: + detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code)) + if detail.shop_id is None: + raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id") + shop_id = detail.shop_id - return await self.search(payload.to_search_request(shop_id)) + result = await self.search(payload.to_search_request(shop_id)) + span.set_attribute("parse.items", len(result.items)) + span.set_attribute("parse.total", result.total_count) + return result + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + raise async def item_detail(self, payload: ItemDetailRequest) -> ItemDetailData: """抓取商品详情""" @@ -137,33 +187,47 @@ class RakutenClient: url = build_item_url(shop_code, item_code) logger.info("抓取商品详情:url=%s", url) - # 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点; - # 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。 - page = await self._session.fetch( - url, mobile=True, validator=build_item_page_validator(url) - ) - - if host_of(page.url) == site.ITEM_HOST: - detail = parse_item_detail( - extract_initial_state(page.html), - item_url=url, - shop_code=shop_code, - include_sku_variants=payload.include_sku_variants, - ) - else: - detail = parse_subsite_item( - SubsitePage( - html=page.html, - requested_url=url, - final_url=page.url, - shop_code=shop_code, - item_code=item_code, - include_sku_variants=payload.include_sku_variants, + html: str | None = None + with tracer.start_as_current_span("parse.rakuten.item_detail") as span: + span.set_attribute("parse.site", "rakuten") + span.set_attribute("parse.op", "item_detail") + try: + # 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点; + # 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。 + page = await self._session.fetch( + url, mobile=True, validator=build_item_page_validator(url) ) - ) + html = page.html - logger.info( - "详情完成:url=%s source=%s name=%s price=%s skus=%s", - url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_count, - ) - return detail + if host_of(page.url) == site.ITEM_HOST: + detail = parse_item_detail( + extract_initial_state(page.html), + item_url=url, + shop_code=shop_code, + include_sku_variants=payload.include_sku_variants, + ) + else: + detail = parse_subsite_item( + SubsitePage( + html=page.html, + requested_url=url, + final_url=page.url, + shop_code=shop_code, + item_code=item_code, + include_sku_variants=payload.include_sku_variants, + ) + ) + + span.set_attribute("parse.source", detail.source) + span.set_attribute("parse.price", detail.price) + span.set_attribute("parse.variant_count", detail.sku.variant_count) + logger.info( + "详情完成:url=%s source=%s name=%s price=%s skus=%s", + url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_count, + ) + return detail + except Exception: + span.record_exception() + span.set_attribute("parse.fail_reason", "parse_error") + snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes) + raise diff --git a/app/scraping/services/site_session.py b/app/scraping/services/site_session.py index 7a069ea..09f8959 100644 --- a/app/scraping/services/site_session.py +++ b/app/scraping/services/site_session.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field from typing import Any import httpx +from opentelemetry import trace from app.scraping.core import site from app.shared.config import Settings @@ -30,6 +31,7 @@ from app.shared.errors import ( ) from app.scraping.parsers.state import PageValidator, require_state_marker from app.scraping.services.browser_fallback import BrowserFallback +from app.shared.telemetry import snapshot logger = logging.getLogger(__name__) @@ -152,66 +154,94 @@ class SiteSession: ResourceBusyError: 等待并发槽位超时 AppError: 校验器判定为不可重试的失败(如落到不支持的站点) """ + tracer = trace.get_tracer(__name__) profile = self._profiles["sp" if mobile else "pc"] max_attempts = max(1, self._settings.http_max_attempts) validate = validator or require_state_marker last_error: str = "unknown error" + # 失败时供 trace snapshot 上报:保留最后一次拿到的响应体(含挑战页 / 5xx 响应)。 + last_text: str | None = None - try: - await asyncio.wait_for( - self._semaphore.acquire(), - timeout=self._settings.request_timeout_seconds, - ) - except TimeoutError as exc: - raise ResourceBusyError() from exc + with tracer.start_as_current_span("scrape.fetch") as span: + span.set_attribute("scrape.url", url) + span.set_attribute("scrape.profile", profile.name) - try: - for attempt in range(1, max_attempts + 1): - await self._ensure_warm(profile) - try: - response = await profile.client.get(url) - except httpx.HTTPError as exc: - last_error = f"{type(exc).__name__}: {exc}" + try: + await asyncio.wait_for( + self._semaphore.acquire(), + timeout=self._settings.request_timeout_seconds, + ) + except TimeoutError as exc: + err = ResourceBusyError() + span.record_exception(err) + span.set_attribute("scrape.fail_reason", "ResourceBusyError") + raise err from exc + + try: + for attempt in range(1, max_attempts + 1): + span.set_attribute("scrape.attempts", attempt) + await self._ensure_warm(profile) + try: + response = await profile.client.get(url) + except httpx.HTTPError as exc: + last_error = f"{type(exc).__name__}: {exc}" + logger.warning( + "抓取请求异常:url=%s profile=%s attempt=%s/%s err=%s", + url, profile.name, attempt, max_attempts, last_error, + ) + continue + + if response.status_code == 404: + err = ItemNotFoundError(f"Page not found: {url}") + span.record_exception(err) + span.set_attribute("scrape.fail_reason", "ItemNotFoundError") + raise err + + text = response.text + final_url = str(response.url) + span.set_attribute("scrape.last_status_code", response.status_code) + span.set_attribute("scrape.final_url", final_url) + span.set_attribute("scrape.html_bytes", len(text)) + + if response.status_code < 400: + # 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截 + reason = validate(text, final_url) + if reason is None: + return FetchedPage(html=text, url=final_url) + last_error = self._refine_failure(reason, text) + if last_error.startswith("challenge page detected"): + span.set_attribute("scrape.challenge_detected", True) + else: + last_error = self._describe_error_status(response.status_code) + last_text = text logger.warning( - "抓取请求异常:url=%s profile=%s attempt=%s/%s err=%s", + "抓取结果异常:url=%s profile=%s attempt=%s/%s %s", url, profile.name, attempt, max_attempts, last_error, ) - continue - if response.status_code == 404: - raise ItemNotFoundError(f"Page not found: {url}") + if attempt >= max_attempts: + break - text = response.text - final_url = str(response.url) - if response.status_code < 400: - # 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截 - reason = validate(text, final_url) - if reason is None: - return FetchedPage(html=text, url=final_url) - last_error = self._refine_failure(reason, text) + # 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器 + if attempt == 1: + await self._invalidate(profile) + else: + page = await self._escalate_to_browser(profile, url, validate) + if page is not None: + span.set_attribute("scrape.fell_back_to_browser", True) + span.set_attribute("scrape.final_url", page.url) + return page + + if self._is_server_error(last_error): + err = UpstreamRequestError(f"Upstream request failed: {last_error}") else: - last_error = self._describe_error_status(response.status_code) - logger.warning( - "抓取结果异常:url=%s profile=%s attempt=%s/%s %s", - url, profile.name, attempt, max_attempts, last_error, - ) - - if attempt >= max_attempts: - break - - # 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器 - if attempt == 1: - await self._invalidate(profile) - else: - page = await self._escalate_to_browser(profile, url, validate) - if page is not None: - return page - - if self._is_server_error(last_error): - raise UpstreamRequestError(f"Upstream request failed: {last_error}") - raise UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}") - finally: - self._semaphore.release() + err = UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}") + span.record_exception(err) + span.set_attribute("scrape.fail_reason", type(err).__name__) + snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes) + raise err + finally: + self._semaphore.release() # ---- 内部 ---- diff --git a/app/shared/config.py b/app/shared/config.py index 917c9f4..f1ba6d1 100644 --- a/app/shared/config.py +++ b/app/shared/config.py @@ -82,6 +82,18 @@ class Settings(BaseSettings): proxy_username: str | None = None proxy_password: str | None = None + # ---- OpenTelemetry traces(通用,可选;默认关闭)---- + # 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到 + # 内容能否被解析"。两侧 main.py 在 lifespan 启动时按服务名分别初始化。 + otel_enabled: bool = False + otel_endpoint: str | None = None # OTLP/HTTP traces endpoint,例如 https://oltp.jerryyan.top/v1/traces + otel_service_name: str = "rakuten" # 仅作兜底;实际值由两侧 main.py 显式覆盖 + otel_headers: str | None = None # OTLP 鉴权头,形如 "k=v,k=v";当前 endpoint 裸跑,留空 + otel_export_interval_ms: int = 5000 + # 解析失败时把页面 HTML 作为 span event 上报的上限字节;超出截断并标注。 + # 单个搜索页 HTML 可达 200KB-2MB,调高时同步关注 OTLP 单次请求大小限制。 + otel_snapshot_max_bytes: int = 2_000_000 + # ---- 登录态与下单配置(仅交易服务)---- # 人工登录一次后落盘的 Playwright storage_state 目录(相对项目根目录)。 # 目录里是可直接冒充账号的 cookie,务必不要提交到版本库。 diff --git a/app/shared/telemetry.py b/app/shared/telemetry.py new file mode 100644 index 0000000..adbf54b --- /dev/null +++ b/app/shared/telemetry.py @@ -0,0 +1,145 @@ +"""OpenTelemetry traces 接入:仅在 otel_enabled=true 时初始化,否则全 noop + +抓取与交易两个进程在 lifespan 启动时各自调用 `setup_telemetry(settings, +service_name=...)`:注册 TracerProvider + OTLP/HTTP exporter + 自动 +instrumentation(FastAPI、httpx)。失败时(如 endpoint 不可达)不阻断主流程, +仅打日志;traces 是辅助观测,不应让进程起不来。 + +`shutdown_telemetry` 在 lifespan 关闭时 force_flush 后再 shutdown,确保缓冲区 +里的 span 都已上报。 + +OTel SDK 默认的 ProxyTracerProvider 在 setup 之前就能用(noop span),所以 +其它代码里直接 `trace.get_tracer(__name__)` + `start_as_current_span` 即可, +不必关心 telemetry 是否启用——禁用时 span 不会真正产生与上报。 +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.trace.sampling import ALWAYS_ON +from opentelemetry.trace import Span +from opentelemetry.util.types import AttributeValue + +from app.shared.config import Settings + +if TYPE_CHECKING: + from fastapi import FastAPI + +logger = logging.getLogger(__name__) + +# 全局 provider 引用,用于 instrument_app / shutdown 时判断当前是否已初始化。 +# 显式持有比依赖 trace.get_tracer_provider() 的类型判断更稳——后者在测试场景 +# 下可能被其它用例改动全局状态。 +_provider: TracerProvider | None = None + + +def setup_telemetry(settings: Settings, *, service_name: str) -> None: + """初始化 OTel:TracerProvider + OTLP exporter + httpx 自动 instrumentation。 + + - `otel_enabled=False` 或 endpoint 未配时仅打日志,不做任何事。 + - 必须在创建任何 httpx.AsyncClient 之前调用,否则 httpx 不会被打桩。 + 两侧 main.py 的 lifespan 已把 setup 放在 site_session.start() 之前。 + - 重复调用安全(_provider 已设时直接返回)。 + """ + global _provider + if _provider is not None: + return + if not settings.otel_enabled or not settings.otel_endpoint: + logger.info("OpenTelemetry 未启用(endpoint 或 otel_enabled 未配置)") + return + + resource = Resource.create({SERVICE_NAME: service_name}) + provider = TracerProvider(resource=resource, sampler=ALWAYS_ON) + + exporter = OTLPSpanExporter( + endpoint=settings.otel_endpoint, + headers=_parse_headers(settings.otel_headers), + timeout=10, + ) + provider.add_span_processor( + BatchSpanProcessor( + exporter, + schedule_delay_millis=settings.otel_export_interval_ms, + ) + ) + trace.set_tracer_provider(provider) + _provider = provider + + # httpx 是抓取/交易两侧唯一的外部 HTTP 客户端,打桩后所有 AsyncClient 请求 + # 自动产生 CLIENT span。失败不影响主链路:进程仍可运行,只是看不到 span。 + try: + HTTPXClientInstrumentor().instrument() + except Exception: + logger.warning("httpx 自动 instrumentation 失败", exc_info=True) + + logger.info( + "OpenTelemetry 已启用:endpoint=%s service=%s", + settings.otel_endpoint, + service_name, + ) + + +def instrument_app(app: "FastAPI") -> None: + """FastAPI 应用打桩;未初始化时 noop,调用顺序无要求。""" + if _provider is None: + return + FastAPIInstrumentor.instrument_app(app) + + +def shutdown_telemetry() -> None: + """flush + shutdown;幂等,未初始化时直接返回。""" + global _provider + if _provider is None: + return + try: + _provider.force_flush() + _provider.shutdown() + except Exception: + logger.debug("关闭 OpenTelemetry provider 失败", exc_info=True) + _provider = None + + +def snapshot(span: Span, name: str, html: str | None, max_bytes: int) -> None: + """把 HTML 作为 span event 上报,超 max_bytes 截断并标注。 + + 用于解析失败时复现页面:span 自身只放结构化指标(items 数、source 等), + 完整 HTML 体量大、含商品/价格内容,仅在失败分支通过 event 携带。 + """ + if html is None or not html: + return + original_bytes = len(html) + truncated = original_bytes > max_bytes + payload = html if not truncated else html[:max_bytes] + attributes: dict[str, AttributeValue] = { + "snapshot.html": payload, + "snapshot.original_bytes": original_bytes, + } + if truncated: + attributes["snapshot.truncated"] = True + span.add_event(name, attributes=attributes) + + +def _parse_headers(raw: str | None) -> list[tuple[str, str]] | None: + """解析 "k1=v1,k2=v2" 形式的 header 配置;空输入返回 None。""" + if not raw or not raw.strip(): + return None + pairs: list[tuple[str, str]] = [] + for chunk in raw.split(","): + key, sep, value = chunk.partition("=") + if not sep or not key.strip(): + continue + pairs.append((key.strip(), value.strip())) + return pairs or None + + +def is_initialized() -> bool: + """供测试断言使用。""" + return _provider is not None diff --git a/app/trading/main.py b/app/trading/main.py index ea1d29b..ecd139e 100644 --- a/app/trading/main.py +++ b/app/trading/main.py @@ -22,6 +22,7 @@ from fastapi import FastAPI from app.shared.api import register_exception_handlers from app.shared.config import get_settings from app.shared.logging_setup import configure_logging +from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry from app.trading.api.routes.auth import router as auth_router from app.trading.api.routes.health import router as health_router from app.trading.container import TradingContainer @@ -43,6 +44,8 @@ async def lifespan(app: FastAPI): app.state.container = container configure_logging(container.settings) + # 与抓取侧同样:必须在创建 httpx 客户端之前 setup。 + setup_telemetry(container.settings, service_name="rakuten-trading") logger.info( "交易服务启动:%s:%s", container.settings.trading_host, @@ -54,6 +57,7 @@ async def lifespan(app: FastAPI): yield finally: await container.auth_session.close() + shutdown_telemetry() def create_app() -> FastAPI: @@ -62,6 +66,7 @@ def create_app() -> FastAPI: app.include_router(health_router) app.include_router(auth_router) register_exception_handlers(app) + instrument_app(app) return app diff --git a/pyproject.toml b/pyproject.toml index 83b9ccb..cae3fc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,11 @@ dependencies = [ "fastapi>=0.115.0,<1.0.0", "httpx[http2]>=0.27.0,<1.0.0", "loguru>=0.7.0,<1.0.0", + "opentelemetry-api>=1.27.0,<2.0.0", + "opentelemetry-exporter-otlp>=1.27.0,<2.0.0", + "opentelemetry-instrumentation-fastapi>=0.48b0,<1.0.0", + "opentelemetry-instrumentation-httpx>=0.48b0,<1.0.0", + "opentelemetry-sdk>=1.27.0,<2.0.0", "pydantic>=2.0.0,<3.0.0", "pydantic-settings>=2.4.0,<3.0.0", "selectolax>=0.3.21,<1.0.0", diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..3cf449d --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,48 @@ +"""telemetry 模块测试 + +验证两件事: +1. 默认配置(otel_enabled=False)下 setup 是 noop,不初始化任何 provider。 +2. enabled=true + endpoint 时 setup 注册真实 TracerProvider;shutdown 复位。 + +不打真实网络:OTLPSpanExporter 创建时不发请求,BatchSpanProcessor 异步批量 +上报在没有 span 产生时也不会触发。 +""" +from __future__ import annotations + +from opentelemetry import trace +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.sdk.trace import TracerProvider + +from app.shared.config import Settings +from app.shared.telemetry import is_initialized, setup_telemetry, shutdown_telemetry + + +def test_disabled_is_noop(): + """otel_enabled=False 时 setup/instrument/shutdown 都不初始化 provider""" + settings = Settings(_env_file=None) # 默认 otel_enabled=False + setup_telemetry(settings, service_name="test-disabled") + assert not is_initialized() + + +def test_enabled_initializes_and_shutdown_releases(): + """enabled=true 时 setup 注册 TracerProvider,shutdown 后 _provider 复位""" + settings = Settings( + _env_file=None, + otel_enabled=True, + otel_endpoint="http://localhost:4318/v1/traces", + ) + try: + setup_telemetry(settings, service_name="test-enabled") + assert is_initialized() + provider = trace.get_tracer_provider() + # 初始化后是真实 SDK provider(class 名是 TracerProvider); + # SDK 启动前的占位 provider 不属于这个类型 + assert isinstance(provider, TracerProvider) + finally: + shutdown_telemetry() + # 解除 httpx 全局 monkey-patch,避免污染其它测试 + try: + HTTPXClientInstrumentor().uninstrument() + except Exception: + pass + assert not is_initialized() diff --git a/uv.lock b/uv.lock index 05064a3..d204551 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "annotated-doc" @@ -32,6 +36,15 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, ] +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -41,6 +54,54 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5" }, +] + [[package]] name = "click" version = "8.4.2" @@ -78,6 +139,18 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/76/6d9e25ad88da9d3ff744bcdbec4736e38c2288611d43f673a5d9bfa27c07/fastapi-0.140.0-py3-none-any.whl", hash = "sha256:e951c0a0d9540bf5d9a2a9e078fd415da2ab7e312d435139e7d9e2e7fe9f0b23" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed" }, +] + [[package]] name = "greenlet" version = "3.5.4" @@ -135,6 +208,37 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994" }, ] +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -268,6 +372,190 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/61/03/a529140241addd4d0acc73bafbd6f74691651b92fc0ae9b4513cf80f07fa/opentelemetry_instrumentation_httpx-0.65b0.tar.gz", hash = "sha256:4627aa9c6bb99bf4462c8b565b0ef6aeb9ffad95c6c92868be1ef7895de112ee" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9d/0f/c6144096b4914bbf44b43ba21c962e8f333ff045770b50a3e79ed8bd455f/opentelemetry_instrumentation_httpx-0.65b0-py3-none-any.whl", hash = "sha256:400f1b78afa4ee2332b5debe58e1ed1b317913d58812c952576be76660aeadb1" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348" }, +] + [[package]] name = "packaging" version = "26.2" @@ -305,6 +593,21 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -492,6 +795,11 @@ dependencies = [ { name = "fastapi" }, { name = "httpx", extra = ["http2"] }, { name = "loguru" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "selectolax" }, @@ -512,6 +820,11 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.27.0,<1.0.0" }, { name = "loguru", specifier = ">=0.7.0,<1.0.0" }, + { name = "opentelemetry-api", specifier = ">=1.27.0,<2.0.0" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.27.0,<2.0.0" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.48b0,<1.0.0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.48b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.27.0,<2.0.0" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.47.0,<2.0.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" }, @@ -522,6 +835,21 @@ requires-dist = [ ] provides-extras = ["browser", "dev"] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, +] + [[package]] name = "selectolax" version = "0.4.11" @@ -590,6 +918,15 @@ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, +] + [[package]] name = "uvicorn" version = "0.51.0" @@ -779,3 +1116,56 @@ sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b3/8f/ wheels = [ { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, ] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" } +sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302" } +wheels = [ + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347" }, + { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048" }, +]