feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
22
apps/scanner/Dockerfile
Normal file
22
apps/scanner/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System dependencies for Playwright Chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 \
|
||||
libdrm2 libgbm1 libgtk-3-0 libnspr4 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libxshmfence1 libpango-1.0-0 libcairo2 \
|
||||
libasound2 libatspi2.0-0 libx11-xcb1 fonts-liberation curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir . \
|
||||
&& playwright install chromium
|
||||
|
||||
COPY src/ src/
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8001/health || exit 1
|
||||
|
||||
CMD ["python", "-m", "src.worker"]
|
||||
28
apps/scanner/fly.toml
Normal file
28
apps/scanner/fly.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
# Fly.io configuration for the ConsentOS Scanner service
|
||||
# Runs the Playwright-based cookie crawler as an internal HTTP service.
|
||||
# See https://fly.io/docs/reference/configuration/ for reference.
|
||||
|
||||
app = "consentos-scanner"
|
||||
primary_region = "lhr" # London — same region as consentos-api
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
HOST = "0.0.0.0"
|
||||
PORT = "8001"
|
||||
LOG_LEVEL = "INFO"
|
||||
CRAWLER_HEADLESS = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8001
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
memory = "512mb"
|
||||
cpu_kind = "shared"
|
||||
cpus = 1
|
||||
39
apps/scanner/pyproject.toml
Normal file
39
apps/scanner/pyproject.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[project]
|
||||
name = "consentos-scanner"
|
||||
version = "0.1.0"
|
||||
description = "ConsentOS — Playwright-based cookie scanner"
|
||||
license = "Elastic-2.0"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
dependencies = [
|
||||
"playwright>=1.49,<2",
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic>=2.0,<3",
|
||||
"pydantic-settings>=2.0,<3",
|
||||
"fastapi>=0.115,<1",
|
||||
"uvicorn[standard]>=0.34,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0,<9",
|
||||
"pytest-asyncio>=0.24,<1",
|
||||
"ruff>=0.8,<1",
|
||||
"pytest-cov>=6.0,<7",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
0
apps/scanner/src/__init__.py
Normal file
0
apps/scanner/src/__init__.py
Normal file
107
apps/scanner/src/classifier.py
Normal file
107
apps/scanner/src/classifier.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Cookie classification based on known patterns.
|
||||
|
||||
Matches discovered cookies against a database of known cookie patterns
|
||||
to auto-categorise them (analytics, marketing, functional, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnownPattern:
|
||||
"""A known cookie pattern for classification."""
|
||||
|
||||
name_pattern: str
|
||||
domain_pattern: str
|
||||
category: str
|
||||
vendor: str | None = None
|
||||
is_regex: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""Result of classifying a cookie."""
|
||||
|
||||
category: str | None
|
||||
vendor: str | None = None
|
||||
match_source: str = "unmatched" # exact | wildcard | regex | unmatched
|
||||
|
||||
|
||||
def classify_cookie(
|
||||
name: str,
|
||||
domain: str,
|
||||
patterns: list[KnownPattern],
|
||||
) -> ClassificationResult:
|
||||
"""Classify a cookie by matching against known patterns.
|
||||
|
||||
Matching priority:
|
||||
1. Exact name match
|
||||
2. Wildcard match (patterns containing *)
|
||||
3. Regex match (patterns flagged as regex)
|
||||
"""
|
||||
for pattern in patterns:
|
||||
if pattern.is_regex:
|
||||
continue # Skip regex in first pass
|
||||
|
||||
if "*" in pattern.name_pattern:
|
||||
# Wildcard match
|
||||
regex = pattern.name_pattern.replace(".", r"\.").replace("*", ".*")
|
||||
if re.match(f"^{regex}$", name, re.IGNORECASE):
|
||||
if _domain_matches(domain, pattern.domain_pattern):
|
||||
return ClassificationResult(
|
||||
category=pattern.category,
|
||||
vendor=pattern.vendor,
|
||||
match_source="wildcard",
|
||||
)
|
||||
elif pattern.name_pattern == name:
|
||||
# Exact match
|
||||
if _domain_matches(domain, pattern.domain_pattern):
|
||||
return ClassificationResult(
|
||||
category=pattern.category,
|
||||
vendor=pattern.vendor,
|
||||
match_source="exact",
|
||||
)
|
||||
|
||||
# Regex pass
|
||||
for pattern in patterns:
|
||||
if not pattern.is_regex:
|
||||
continue
|
||||
try:
|
||||
if re.match(pattern.name_pattern, name, re.IGNORECASE):
|
||||
if _domain_matches(domain, pattern.domain_pattern):
|
||||
return ClassificationResult(
|
||||
category=pattern.category,
|
||||
vendor=pattern.vendor,
|
||||
match_source="regex",
|
||||
)
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
return ClassificationResult(category=None, match_source="unmatched")
|
||||
|
||||
|
||||
def _domain_matches(actual: str, pattern: str) -> bool:
|
||||
"""Check if a domain matches a pattern.
|
||||
|
||||
Patterns can be:
|
||||
- "*" — matches any domain
|
||||
- ".example.com" — matches example.com and *.example.com
|
||||
- "example.com" — exact match
|
||||
"""
|
||||
if pattern == "*":
|
||||
return True
|
||||
|
||||
actual = actual.lower().lstrip(".")
|
||||
pattern = pattern.lower().lstrip(".")
|
||||
|
||||
if actual == pattern:
|
||||
return True
|
||||
|
||||
# Subdomain match: actual "sub.example.com" matches pattern "example.com"
|
||||
if actual.endswith(f".{pattern}"):
|
||||
return True
|
||||
|
||||
return False
|
||||
280
apps/scanner/src/consent_validator.py
Normal file
280
apps/scanner/src/consent_validator.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Consent signal validation — Playwright-based runtime checks.
|
||||
|
||||
Validates that consent signals (GCM, TCF, GPP) work correctly at runtime
|
||||
by checking pre-consent, post-accept, and post-reject states.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from playwright.async_api import BrowserContext, Page
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Known tracker domains for pixel-fire detection
|
||||
KNOWN_TRACKER_DOMAINS = frozenset(
|
||||
{
|
||||
"google-analytics.com",
|
||||
"googletagmanager.com",
|
||||
"doubleclick.net",
|
||||
"facebook.net",
|
||||
"facebook.com",
|
||||
"connect.facebook.net",
|
||||
"analytics.tiktok.com",
|
||||
"snap.licdn.com",
|
||||
"bat.bing.com",
|
||||
"clarity.ms",
|
||||
"hotjar.com",
|
||||
"mouseflow.com",
|
||||
"cdn.segment.com",
|
||||
"cdn.mxpnl.com",
|
||||
"plausible.io",
|
||||
"px.ads.linkedin.com",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsentSignalState:
|
||||
"""Captured consent signal state from the page."""
|
||||
|
||||
gcm_state: dict | None = None
|
||||
tcf_data: dict | None = None
|
||||
gpp_data: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""A single consent validation issue."""
|
||||
|
||||
check: str
|
||||
severity: str # critical, warning, info
|
||||
message: str
|
||||
recommendation: str
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of consent signal validation for a page."""
|
||||
|
||||
url: str
|
||||
pre_consent_issues: list[ValidationIssue] = field(default_factory=list)
|
||||
post_accept_issues: list[ValidationIssue] = field(default_factory=list)
|
||||
post_reject_issues: list[ValidationIssue] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def all_issues(self) -> list[ValidationIssue]:
|
||||
return self.pre_consent_issues + self.post_accept_issues + self.post_reject_issues
|
||||
|
||||
@property
|
||||
def has_issues(self) -> bool:
|
||||
return bool(self.all_issues)
|
||||
|
||||
|
||||
async def _get_consent_signals(page: Page) -> ConsentSignalState:
|
||||
"""Extract current consent signal state from the page."""
|
||||
state = ConsentSignalState()
|
||||
|
||||
# Read GCM state
|
||||
try:
|
||||
gcm = await page.evaluate("""() => {
|
||||
try {
|
||||
if (window.dataLayer) {
|
||||
const consentEvents = window.dataLayer.filter(
|
||||
e => e[0] === 'consent' || (e.event && e.event.includes('consent'))
|
||||
);
|
||||
return { dataLayer: consentEvents, available: true };
|
||||
}
|
||||
return { available: false };
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}""")
|
||||
state.gcm_state = gcm
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read TCF state
|
||||
try:
|
||||
tcf = await page.evaluate("""() => {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window.__tcfapi === 'function') {
|
||||
window.__tcfapi('getTCData', 2, (data, success) => {
|
||||
resolve({ available: true, success, data: data || null });
|
||||
});
|
||||
} else {
|
||||
resolve({ available: false });
|
||||
}
|
||||
});
|
||||
}""")
|
||||
state.tcf_data = tcf
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read GPP state
|
||||
try:
|
||||
gpp = await page.evaluate("""() => {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window.__gpp === 'function') {
|
||||
window.__gpp('getGPPData', (data, success) => {
|
||||
resolve({ available: true, success, data: data || null });
|
||||
});
|
||||
} else {
|
||||
resolve({ available: false });
|
||||
}
|
||||
});
|
||||
}""")
|
||||
state.gpp_data = gpp
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return state
|
||||
|
||||
|
||||
async def _get_cookies_from_context(context: BrowserContext) -> list[dict]:
|
||||
"""Get all cookies from the browser context."""
|
||||
return await context.cookies()
|
||||
|
||||
|
||||
def _is_tracker_request(url: str) -> bool:
|
||||
"""Check if a URL belongs to a known tracker domain."""
|
||||
for domain in KNOWN_TRACKER_DOMAINS:
|
||||
if domain in url:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def validate_pre_consent(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
essential_cookie_names: set[str],
|
||||
tracker_requests: list[str],
|
||||
) -> list[ValidationIssue]:
|
||||
"""Validate that no non-essential activity occurs before consent."""
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
# Check cookies — only essential should be set
|
||||
cookies = await _get_cookies_from_context(context)
|
||||
non_essential = [c for c in cookies if c["name"] not in essential_cookie_names]
|
||||
if non_essential:
|
||||
names = [c["name"] for c in non_essential]
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="pre_consent_cookies",
|
||||
severity="critical",
|
||||
message=(
|
||||
f"{len(non_essential)} non-essential cookie(s) set before consent: "
|
||||
f"{', '.join(names[:5])}"
|
||||
),
|
||||
recommendation=(
|
||||
"Ensure all non-essential cookies are blocked until consent is given."
|
||||
),
|
||||
details={"cookies": names},
|
||||
)
|
||||
)
|
||||
|
||||
# Check tracker requests
|
||||
tracker_hits = [url for url in tracker_requests if _is_tracker_request(url)]
|
||||
if tracker_hits:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="pre_consent_trackers",
|
||||
severity="critical",
|
||||
message=f"{len(tracker_hits)} tracking request(s) fired before consent.",
|
||||
recommendation="Block all tracking scripts until the user grants consent.",
|
||||
details={"tracker_urls": tracker_hits[:10]},
|
||||
)
|
||||
)
|
||||
|
||||
# Check GCM defaults
|
||||
signals = await _get_consent_signals(page)
|
||||
if signals.gcm_state and signals.gcm_state.get("available"):
|
||||
# GCM should show denied for non-essential types
|
||||
pass # GCM state captured for reporting
|
||||
|
||||
# Check TCF — no purpose consents should be active
|
||||
if signals.tcf_data and signals.tcf_data.get("available"):
|
||||
tcf_data = signals.tcf_data.get("data") or {}
|
||||
purpose_consents = tcf_data.get("purpose", {}).get("consents", {})
|
||||
granted_purposes = [k for k, v in purpose_consents.items() if v]
|
||||
if granted_purposes:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="pre_consent_tcf",
|
||||
severity="critical",
|
||||
message=f"TCF purpose consents active before user action: {granted_purposes}",
|
||||
recommendation="TCF should report no purpose consents until user grants them.",
|
||||
details={"granted_purposes": granted_purposes},
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def validate_post_accept(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
) -> list[ValidationIssue]:
|
||||
"""Validate consent signals after Accept All is clicked."""
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
signals = await _get_consent_signals(page)
|
||||
|
||||
# Check TCF — purposes should now be consented
|
||||
if signals.tcf_data and signals.tcf_data.get("available"):
|
||||
if not signals.tcf_data.get("success"):
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="post_accept_tcf",
|
||||
severity="warning",
|
||||
message="TCF getTCData returned unsuccessful after Accept All.",
|
||||
recommendation=("Verify TCF API returns valid TC data after consent."),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def validate_post_reject(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
essential_cookie_names: set[str],
|
||||
tracker_requests: list[str],
|
||||
) -> list[ValidationIssue]:
|
||||
"""Validate that rejection is respected — no tracking after reject."""
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
# Check cookies after reject
|
||||
cookies = await _get_cookies_from_context(context)
|
||||
non_essential = [c for c in cookies if c["name"] not in essential_cookie_names]
|
||||
if non_essential:
|
||||
names = [c["name"] for c in non_essential]
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="post_reject_cookies",
|
||||
severity="critical",
|
||||
message=(
|
||||
f"{len(non_essential)} non-essential cookie(s) remain after rejection: "
|
||||
f"{', '.join(names[:5])}"
|
||||
),
|
||||
recommendation="Ensure all non-essential cookies are removed when user rejects.",
|
||||
details={"cookies": names},
|
||||
)
|
||||
)
|
||||
|
||||
# Check tracker requests after reject
|
||||
tracker_hits = [url for url in tracker_requests if _is_tracker_request(url)]
|
||||
if tracker_hits:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
check="post_reject_trackers",
|
||||
severity="critical",
|
||||
message=f"{len(tracker_hits)} tracking request(s) fired after rejection.",
|
||||
recommendation="Ensure tracking scripts respect rejection and do not fire.",
|
||||
details={"tracker_urls": tracker_hits[:10]},
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
335
apps/scanner/src/crawler.py
Normal file
335
apps/scanner/src/crawler.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Playwright-based headless browser cookie crawler.
|
||||
|
||||
For each URL: launches headless Chromium, clears cookies, navigates,
|
||||
waits for network idle, enumerates document.cookie / localStorage /
|
||||
sessionStorage, captures Set-Cookie headers from network requests,
|
||||
and attributes cookies to source scripts via the request chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.async_api import (
|
||||
BrowserContext,
|
||||
Page,
|
||||
Request,
|
||||
Response,
|
||||
async_playwright,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Realistic Chrome UA so sites don't block the crawler as a bot.
|
||||
_DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredCookie:
|
||||
"""A cookie or storage item found during a crawl."""
|
||||
|
||||
name: str
|
||||
domain: str
|
||||
storage_type: str = "cookie" # cookie | local_storage | session_storage
|
||||
path: str | None = None
|
||||
expires: float | None = None
|
||||
http_only: bool | None = None
|
||||
secure: bool | None = None
|
||||
same_site: str | None = None
|
||||
value_length: int = 0
|
||||
script_source: str | None = None
|
||||
page_url: str = ""
|
||||
initiator_chain: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlResult:
|
||||
"""Result of crawling a single page."""
|
||||
|
||||
url: str
|
||||
cookies: list[DiscoveredCookie] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SiteCrawlResult:
|
||||
"""Aggregated result of crawling all pages on a site."""
|
||||
|
||||
domain: str
|
||||
pages: list[CrawlResult] = field(default_factory=list)
|
||||
total_cookies_found: int = 0
|
||||
|
||||
@property
|
||||
def unique_cookies(self) -> list[DiscoveredCookie]:
|
||||
"""Deduplicate cookies across pages by (name, domain, storage_type)."""
|
||||
seen: dict[tuple[str, str, str], DiscoveredCookie] = {}
|
||||
for page in self.pages:
|
||||
for cookie in page.cookies:
|
||||
key = (cookie.name, cookie.domain, cookie.storage_type)
|
||||
if key not in seen:
|
||||
seen[key] = cookie
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyConfig:
|
||||
"""Proxy configuration for geo-located scanning."""
|
||||
|
||||
server: str # e.g. "http://proxy-eu.example.com:8080"
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class CookieCrawler:
|
||||
"""Crawls a site using Playwright to discover cookies and storage items."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
headless: bool = True,
|
||||
timeout_ms: int = 30_000,
|
||||
user_agent: str = _DEFAULT_USER_AGENT,
|
||||
proxy: ProxyConfig | None = None,
|
||||
) -> None:
|
||||
self._headless = headless
|
||||
self._timeout_ms = timeout_ms
|
||||
self._user_agent = user_agent
|
||||
self._proxy = proxy
|
||||
|
||||
async def crawl_site(
|
||||
self,
|
||||
urls: list[str],
|
||||
*,
|
||||
max_pages: int = 50,
|
||||
) -> SiteCrawlResult:
|
||||
"""Crawl multiple URLs and aggregate cookie discoveries."""
|
||||
if not urls:
|
||||
return SiteCrawlResult(domain="")
|
||||
|
||||
domain = urlparse(urls[0]).hostname or ""
|
||||
result = SiteCrawlResult(domain=domain)
|
||||
|
||||
async with async_playwright() as pw:
|
||||
launch_kwargs: dict = {"headless": self._headless}
|
||||
if self._proxy:
|
||||
proxy_opts: dict = {"server": self._proxy.server}
|
||||
if self._proxy.username:
|
||||
proxy_opts["username"] = self._proxy.username
|
||||
if self._proxy.password:
|
||||
proxy_opts["password"] = self._proxy.password
|
||||
launch_kwargs["proxy"] = proxy_opts
|
||||
browser = await pw.chromium.launch(**launch_kwargs)
|
||||
try:
|
||||
for url in urls[:max_pages]:
|
||||
page_result = await self._crawl_page(browser, url)
|
||||
result.pages.append(page_result)
|
||||
result.total_cookies_found += len(page_result.cookies)
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
return result
|
||||
|
||||
async def _crawl_page(
|
||||
self,
|
||||
browser: Browser, # noqa: F821
|
||||
url: str,
|
||||
) -> CrawlResult:
|
||||
"""Crawl a single page and discover cookies."""
|
||||
result = CrawlResult(url=url)
|
||||
script_cookies: dict[str, str] = {} # cookie name → script URL
|
||||
initiator_map: dict[str, str] = {} # request URL → initiating URL
|
||||
initiator_chains: dict[str, list[str]] = {} # cookie name → chain
|
||||
|
||||
context: BrowserContext | None = None
|
||||
try:
|
||||
context = await browser.new_context(
|
||||
user_agent=self._user_agent,
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
# Clear all cookies before visiting
|
||||
await context.clear_cookies()
|
||||
|
||||
page: Page = await context.new_page()
|
||||
|
||||
# Track request initiator chains via frame URL and redirect chains
|
||||
def _on_request(request: Request) -> None:
|
||||
try:
|
||||
req_url = request.url
|
||||
# Follow redirect chain to find the original initiator
|
||||
redirected = request.redirected_from
|
||||
if redirected:
|
||||
initiator_map[req_url] = redirected.url
|
||||
else:
|
||||
# Use the frame URL as the parent initiator
|
||||
frame_url = request.frame.url if request.frame else ""
|
||||
if frame_url and frame_url != req_url:
|
||||
initiator_map[req_url] = frame_url
|
||||
except Exception:
|
||||
pass # Non-critical — request introspection may fail
|
||||
|
||||
page.on("request", _on_request)
|
||||
|
||||
# Track Set-Cookie headers from responses
|
||||
async def _on_response(response: Response) -> None:
|
||||
try:
|
||||
headers = await response.all_headers()
|
||||
set_cookie = headers.get("set-cookie", "")
|
||||
if set_cookie:
|
||||
# Attribute cookie to the initiating script
|
||||
request: Request = response.request
|
||||
initiator = _get_script_initiator(request)
|
||||
# Build the initiator chain for this request
|
||||
chain = _build_initiator_chain(request.url, initiator_map)
|
||||
for cookie_str in set_cookie.split("\n"):
|
||||
name = cookie_str.split("=")[0].strip()
|
||||
if name:
|
||||
if initiator:
|
||||
script_cookies[name] = initiator
|
||||
initiator_chains[name] = chain
|
||||
except Exception:
|
||||
pass # Non-critical — response may have been aborted
|
||||
|
||||
page.on("response", _on_response)
|
||||
|
||||
# Navigate
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=self._timeout_ms)
|
||||
# Allow additional time for scripts to set cookies after DOM load.
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Enumerate browser cookies via CDP
|
||||
cdp_cookies = await context.cookies()
|
||||
for c in cdp_cookies:
|
||||
result.cookies.append(
|
||||
DiscoveredCookie(
|
||||
name=c["name"],
|
||||
domain=c["domain"],
|
||||
storage_type="cookie",
|
||||
path=c.get("path"),
|
||||
expires=c.get("expires"),
|
||||
http_only=c.get("httpOnly"),
|
||||
secure=c.get("secure"),
|
||||
same_site=c.get("sameSite"),
|
||||
value_length=len(c.get("value", "")),
|
||||
script_source=script_cookies.get(c["name"]),
|
||||
page_url=url,
|
||||
initiator_chain=initiator_chains.get(c["name"], []),
|
||||
)
|
||||
)
|
||||
|
||||
# Enumerate localStorage
|
||||
ls_items = await page.evaluate("""() => {
|
||||
const items = [];
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key) {
|
||||
items.push({
|
||||
name: key,
|
||||
valueLength: (localStorage.getItem(key) || '').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return items;
|
||||
}""")
|
||||
hostname = urlparse(url).hostname or ""
|
||||
for item in ls_items:
|
||||
result.cookies.append(
|
||||
DiscoveredCookie(
|
||||
name=item["name"],
|
||||
domain=hostname,
|
||||
storage_type="local_storage",
|
||||
value_length=item["valueLength"],
|
||||
page_url=url,
|
||||
)
|
||||
)
|
||||
|
||||
# Enumerate sessionStorage
|
||||
ss_items = await page.evaluate("""() => {
|
||||
const items = [];
|
||||
try {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key) {
|
||||
items.push({
|
||||
name: key,
|
||||
valueLength: (sessionStorage.getItem(key) || '').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return items;
|
||||
}""")
|
||||
for item in ss_items:
|
||||
result.cookies.append(
|
||||
DiscoveredCookie(
|
||||
name=item["name"],
|
||||
domain=hostname,
|
||||
storage_type="session_storage",
|
||||
value_length=item["valueLength"],
|
||||
page_url=url,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
result.error = str(exc)
|
||||
logger.warning("Failed to crawl %s: %s", url, exc)
|
||||
finally:
|
||||
if context:
|
||||
await context.close()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_script_initiator(request: Request) -> str | None:
|
||||
"""Walk the request chain to find the originating script URL.
|
||||
|
||||
Returns a single script URL for backwards compatibility. For the full
|
||||
initiator path, use :func:`_build_initiator_chain` instead.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
current = request
|
||||
while current:
|
||||
url = current.url
|
||||
if url in seen:
|
||||
break
|
||||
seen.add(url)
|
||||
if url.endswith(".js") or "javascript" in (current.resource_type or ""):
|
||||
return url
|
||||
redirected = current.redirected_from
|
||||
if redirected:
|
||||
current = redirected
|
||||
else:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _build_initiator_chain(
|
||||
url: str,
|
||||
initiator_map: dict[str, str],
|
||||
max_depth: int = 20,
|
||||
) -> list[str]:
|
||||
"""Build the full initiator chain from a URL back to the root.
|
||||
|
||||
Walks the initiator map from *url* towards the top-level page,
|
||||
producing a list ordered root-first (i.e. the page URL at index 0
|
||||
and the leaf request URL at the end).
|
||||
"""
|
||||
chain = [url]
|
||||
seen: set[str] = {url}
|
||||
current = url
|
||||
for _ in range(max_depth):
|
||||
parent = initiator_map.get(current, "")
|
||||
if not parent or parent in seen:
|
||||
break
|
||||
chain.append(parent)
|
||||
seen.add(parent)
|
||||
current = parent
|
||||
chain.reverse() # Root first
|
||||
return chain
|
||||
348
apps/scanner/src/dark_pattern_detector.py
Normal file
348
apps/scanner/src/dark_pattern_detector.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Dark pattern detection — CSS and DOM analysis of consent banners.
|
||||
|
||||
Detects manipulative UI patterns in cookie consent banners:
|
||||
- Unequal button prominence (Accept bigger/brighter than Reject)
|
||||
- Pre-ticked category checkboxes
|
||||
- Missing first-layer Reject button (CNIL violation)
|
||||
- Cookie walls (blocking page content)
|
||||
- Dismiss-on-scroll (not valid consent under GDPR)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from playwright.async_api import Page
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DarkPatternIssue:
|
||||
"""A detected dark pattern in the consent banner."""
|
||||
|
||||
pattern: str
|
||||
severity: str # critical, warning, info
|
||||
message: str
|
||||
recommendation: str
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DarkPatternResult:
|
||||
"""Result of dark pattern analysis."""
|
||||
|
||||
url: str
|
||||
issues: list[DarkPatternIssue] = field(default_factory=list)
|
||||
banner_found: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Common selectors for consent banner elements
|
||||
BANNER_SELECTORS = [
|
||||
"[id*='cookie']",
|
||||
"[id*='consent']",
|
||||
"[class*='cookie']",
|
||||
"[class*='consent']",
|
||||
"[id*='cmp']",
|
||||
"[class*='cmp']",
|
||||
"[role='dialog'][aria-label*='cookie' i]",
|
||||
"[role='dialog'][aria-label*='consent' i]",
|
||||
]
|
||||
|
||||
ACCEPT_BUTTON_SELECTORS = [
|
||||
"button:has-text('Accept')",
|
||||
"button:has-text('Accept All')",
|
||||
"button:has-text('Allow')",
|
||||
"button:has-text('Allow All')",
|
||||
"button:has-text('I Agree')",
|
||||
"button:has-text('OK')",
|
||||
"button:has-text('Got it')",
|
||||
"[data-action='accept']",
|
||||
"[id*='accept']",
|
||||
]
|
||||
|
||||
REJECT_BUTTON_SELECTORS = [
|
||||
"button:has-text('Reject')",
|
||||
"button:has-text('Reject All')",
|
||||
"button:has-text('Decline')",
|
||||
"button:has-text('Deny')",
|
||||
"button:has-text('Refuse')",
|
||||
"button:has-text('Tout refuser')",
|
||||
"[data-action='reject']",
|
||||
"[id*='reject']",
|
||||
]
|
||||
|
||||
|
||||
async def _find_banner(page: Page) -> bool:
|
||||
"""Check if a consent banner is visible on the page."""
|
||||
for selector in BANNER_SELECTORS:
|
||||
try:
|
||||
elements = await page.query_selector_all(selector)
|
||||
for el in elements:
|
||||
if await el.is_visible():
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
async def _find_button(page: Page, selectors: list[str]) -> dict | None:
|
||||
"""Find a visible button matching one of the selectors, return its computed styles."""
|
||||
for selector in selectors:
|
||||
try:
|
||||
elements = await page.query_selector_all(selector)
|
||||
for el in elements:
|
||||
if await el.is_visible():
|
||||
styles = await el.evaluate("""(el) => {
|
||||
const cs = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
area: rect.width * rect.height,
|
||||
backgroundColor: cs.backgroundColor,
|
||||
color: cs.color,
|
||||
fontSize: parseFloat(cs.fontSize),
|
||||
fontWeight: cs.fontWeight,
|
||||
padding: cs.padding,
|
||||
text: el.textContent.trim(),
|
||||
visible: true,
|
||||
};
|
||||
}""")
|
||||
return styles
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def check_button_prominence(page: Page) -> list[DarkPatternIssue]:
|
||||
"""Compare Accept and Reject button sizes and visual weight."""
|
||||
issues: list[DarkPatternIssue] = []
|
||||
|
||||
accept_btn = await _find_button(page, ACCEPT_BUTTON_SELECTORS)
|
||||
reject_btn = await _find_button(page, REJECT_BUTTON_SELECTORS)
|
||||
|
||||
if not accept_btn:
|
||||
return issues # No accept button found — nothing to compare
|
||||
|
||||
if not reject_btn:
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="missing_reject_button",
|
||||
severity="critical",
|
||||
message="No visible Reject/Decline button found on the first layer.",
|
||||
recommendation=(
|
||||
"Add a clearly visible 'Reject All' button on the first layer "
|
||||
"of the consent banner, as required by GDPR and CNIL."
|
||||
),
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
# Compare button areas
|
||||
accept_area = accept_btn.get("area", 0)
|
||||
reject_area = reject_btn.get("area", 0)
|
||||
|
||||
if reject_area > 0 and accept_area > 0:
|
||||
ratio = accept_area / reject_area
|
||||
if ratio > 1.5:
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="unequal_button_size",
|
||||
severity="warning",
|
||||
message=(
|
||||
f"Accept button is {ratio:.1f}x larger than Reject button. "
|
||||
"Buttons should have equal prominence."
|
||||
),
|
||||
recommendation=(
|
||||
"Make the Accept and Reject buttons the same size and visual weight."
|
||||
),
|
||||
details={
|
||||
"accept_area": accept_area,
|
||||
"reject_area": reject_area,
|
||||
"ratio": round(ratio, 2),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Compare font sizes
|
||||
accept_font = accept_btn.get("fontSize", 0)
|
||||
reject_font = reject_btn.get("fontSize", 0)
|
||||
|
||||
if reject_font > 0 and accept_font > reject_font * 1.3:
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="unequal_font_size",
|
||||
severity="warning",
|
||||
message=(
|
||||
f"Accept button font ({accept_font}px) is larger than "
|
||||
f"Reject button font ({reject_font}px)."
|
||||
),
|
||||
recommendation="Use the same font size for both Accept and Reject buttons.",
|
||||
details={
|
||||
"accept_font_size": accept_font,
|
||||
"reject_font_size": reject_font,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def check_pre_ticked_boxes(page: Page) -> list[DarkPatternIssue]:
|
||||
"""Check for pre-ticked non-essential category checkboxes."""
|
||||
issues: list[DarkPatternIssue] = []
|
||||
|
||||
try:
|
||||
pre_ticked = await page.evaluate("""() => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
'input[type="checkbox"][checked], input[type="checkbox"]:checked'
|
||||
);
|
||||
const results = [];
|
||||
for (const cb of checkboxes) {
|
||||
// Skip if it looks like an "essential" checkbox (often disabled)
|
||||
if (cb.disabled) continue;
|
||||
const label = cb.closest('label')?.textContent?.trim()
|
||||
|| cb.getAttribute('aria-label')
|
||||
|| cb.name
|
||||
|| 'unknown';
|
||||
// Skip checkboxes that appear to be for essential/necessary
|
||||
const labelLower = label.toLowerCase();
|
||||
if (labelLower.includes('essential') || labelLower.includes('necessary')
|
||||
|| labelLower.includes('required') || labelLower.includes('strictly')) {
|
||||
continue;
|
||||
}
|
||||
results.push({ name: cb.name || cb.id, label: label });
|
||||
}
|
||||
return results;
|
||||
}""")
|
||||
|
||||
if pre_ticked:
|
||||
labels = [pt["label"][:50] for pt in pre_ticked]
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="pre_ticked_checkboxes",
|
||||
severity="critical",
|
||||
message=(
|
||||
f"{len(pre_ticked)} non-essential category checkbox(es) are pre-ticked: "
|
||||
f"{', '.join(labels[:3])}"
|
||||
),
|
||||
recommendation=(
|
||||
"Non-essential category checkboxes must default to unchecked. "
|
||||
"Pre-ticked boxes do not constitute valid consent under GDPR."
|
||||
),
|
||||
details={"checkboxes": pre_ticked},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Pre-ticked checkbox check failed: %s", exc)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def check_cookie_wall(page: Page) -> list[DarkPatternIssue]:
|
||||
"""Check if a cookie wall blocks access to page content."""
|
||||
issues: list[DarkPatternIssue] = []
|
||||
|
||||
try:
|
||||
is_wall = await page.evaluate("""() => {
|
||||
// Check for full-screen overlays blocking content
|
||||
const overlays = document.querySelectorAll(
|
||||
'[class*="overlay"], [class*="modal"], [class*="wall"]'
|
||||
);
|
||||
for (const overlay of overlays) {
|
||||
const cs = window.getComputedStyle(overlay);
|
||||
const rect = overlay.getBoundingClientRect();
|
||||
// Full-viewport overlay with high z-index suggests a cookie wall
|
||||
if (rect.width >= window.innerWidth * 0.9
|
||||
&& rect.height >= window.innerHeight * 0.9
|
||||
&& parseInt(cs.zIndex) > 100) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if body/main is hidden or has overflow hidden
|
||||
const body = document.body;
|
||||
const bodyStyle = window.getComputedStyle(body);
|
||||
if (bodyStyle.overflow === 'hidden' && bodyStyle.position === 'fixed') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}""")
|
||||
|
||||
if is_wall:
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="cookie_wall",
|
||||
severity="critical",
|
||||
message="Cookie wall detected — page content appears blocked until consent.",
|
||||
recommendation=(
|
||||
"Remove the cookie wall. Users must be able to access the site "
|
||||
"without being forced to consent to non-essential cookies."
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Cookie wall check failed: %s", exc)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def check_scroll_dismissal(page: Page) -> list[DarkPatternIssue]:
|
||||
"""Check if scrolling dismisses the consent banner (not valid consent)."""
|
||||
issues: list[DarkPatternIssue] = []
|
||||
|
||||
try:
|
||||
# Check if banner is visible before scroll
|
||||
banner_visible_before = await _find_banner(page)
|
||||
if not banner_visible_before:
|
||||
return issues
|
||||
|
||||
# Scroll down
|
||||
await page.evaluate("window.scrollBy(0, 500)")
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
# Check if banner disappeared
|
||||
banner_visible_after = await _find_banner(page)
|
||||
|
||||
if banner_visible_before and not banner_visible_after:
|
||||
issues.append(
|
||||
DarkPatternIssue(
|
||||
pattern="scroll_dismissal",
|
||||
severity="critical",
|
||||
message="Consent banner dismissed on scroll — this is not valid consent.",
|
||||
recommendation=(
|
||||
"Disable dismiss-on-scroll. Under GDPR, scrolling does not "
|
||||
"constitute valid consent. The banner must remain until the user "
|
||||
"makes an explicit choice."
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Scroll dismissal check failed: %s", exc)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def detect_dark_patterns(page: Page) -> DarkPatternResult:
|
||||
"""Run all dark pattern checks on the current page."""
|
||||
url = page.url
|
||||
result = DarkPatternResult(url=url)
|
||||
|
||||
try:
|
||||
result.banner_found = await _find_banner(page)
|
||||
if not result.banner_found:
|
||||
return result
|
||||
|
||||
# Run all checks
|
||||
result.issues.extend(await check_button_prominence(page))
|
||||
result.issues.extend(await check_pre_ticked_boxes(page))
|
||||
result.issues.extend(await check_cookie_wall(page))
|
||||
result.issues.extend(await check_scroll_dismissal(page))
|
||||
|
||||
except Exception as exc:
|
||||
result.error = str(exc)
|
||||
logger.warning("Dark pattern detection failed for %s: %s", url, exc)
|
||||
|
||||
return result
|
||||
119
apps/scanner/src/sitemap.py
Normal file
119
apps/scanner/src/sitemap.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Sitemap parser for URL discovery.
|
||||
|
||||
Fetches and parses XML sitemaps (including sitemap indexes) to discover
|
||||
URLs for crawling. Falls back to common page paths if no sitemap exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# XML namespace used in sitemaps
|
||||
_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
||||
|
||||
# Common page paths to try when no sitemap is available
|
||||
_DEFAULT_PATHS = [
|
||||
"/",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/privacy",
|
||||
"/privacy-policy",
|
||||
"/terms",
|
||||
"/cookie-policy",
|
||||
]
|
||||
|
||||
|
||||
async def discover_urls(
|
||||
domain: str,
|
||||
*,
|
||||
max_urls: int = 50,
|
||||
timeout: float = 10.0,
|
||||
) -> list[str]:
|
||||
"""Discover URLs for a domain via sitemap or fallback paths.
|
||||
|
||||
Attempts to fetch /sitemap.xml first. If that fails, tries
|
||||
/robots.txt for a Sitemap directive. Falls back to default paths.
|
||||
"""
|
||||
base = f"https://{domain}"
|
||||
urls: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
verify=False, # noqa: S501 — scanning may target sites with self-signed certs
|
||||
) as client:
|
||||
# Try sitemap.xml
|
||||
sitemap_urls = await _fetch_sitemap(client, f"{base}/sitemap.xml", max_urls)
|
||||
if sitemap_urls:
|
||||
return sitemap_urls[:max_urls]
|
||||
|
||||
# Try robots.txt for Sitemap directive
|
||||
sitemap_url = await _find_sitemap_in_robots(client, f"{base}/robots.txt")
|
||||
if sitemap_url:
|
||||
sitemap_urls = await _fetch_sitemap(client, sitemap_url, max_urls)
|
||||
if sitemap_urls:
|
||||
return sitemap_urls[:max_urls]
|
||||
|
||||
# Fallback to default paths
|
||||
urls = [f"{base}{path}" for path in _DEFAULT_PATHS]
|
||||
return urls[:max_urls]
|
||||
|
||||
|
||||
async def _fetch_sitemap(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
max_urls: int,
|
||||
) -> list[str]:
|
||||
"""Fetch and parse an XML sitemap. Handles sitemap indexes."""
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
root = ElementTree.fromstring(resp.text)
|
||||
|
||||
# Check if it's a sitemap index
|
||||
sitemaps = root.findall("sm:sitemap/sm:loc", _NS)
|
||||
if sitemaps:
|
||||
urls: list[str] = []
|
||||
for sm_loc in sitemaps:
|
||||
if sm_loc.text:
|
||||
child_urls = await _fetch_sitemap(client, sm_loc.text, max_urls - len(urls))
|
||||
urls.extend(child_urls)
|
||||
if len(urls) >= max_urls:
|
||||
break
|
||||
return urls[:max_urls]
|
||||
|
||||
# Regular sitemap — extract <loc> URLs
|
||||
locs = root.findall("sm:url/sm:loc", _NS)
|
||||
return [loc.text for loc in locs if loc.text][:max_urls]
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch sitemap %s: %s", url, exc)
|
||||
return []
|
||||
|
||||
|
||||
async def _find_sitemap_in_robots(
|
||||
client: httpx.AsyncClient,
|
||||
robots_url: str,
|
||||
) -> str | None:
|
||||
"""Look for a Sitemap directive in robots.txt."""
|
||||
try:
|
||||
resp = await client.get(robots_url)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
for line in resp.text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.lower().startswith("sitemap:"):
|
||||
return stripped.split(":", 1)[1].strip()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
379
apps/scanner/src/worker.py
Normal file
379
apps/scanner/src/worker.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Scanner HTTP service.
|
||||
|
||||
Exposes an HTTP endpoint that accepts scan requests, runs the Playwright
|
||||
cookie crawler, and returns discovered cookies. Called by the API's Celery
|
||||
worker to execute scan jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ScannerSettings(BaseSettings):
|
||||
"""Scanner service settings from environment."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8001
|
||||
log_level: str = "INFO"
|
||||
crawler_timeout_ms: int = 30_000
|
||||
crawler_headless: bool = True
|
||||
max_pages_per_scan: int = 50
|
||||
|
||||
|
||||
# ── Request / Response schemas ───────────────────────────────────────
|
||||
|
||||
|
||||
class ProxyRequest(BaseModel):
|
||||
"""Proxy configuration for geo-located scanning."""
|
||||
|
||||
server: str
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
"""Incoming scan request from the API worker."""
|
||||
|
||||
domain: str
|
||||
urls: list[str] = Field(default_factory=list)
|
||||
max_pages: int = 50
|
||||
proxy: ProxyRequest | None = None
|
||||
|
||||
|
||||
class DiscoveredCookieResponse(BaseModel):
|
||||
"""A single cookie found during crawling."""
|
||||
|
||||
name: str
|
||||
domain: str
|
||||
storage_type: str = "cookie"
|
||||
path: str | None = None
|
||||
expires: float | None = None
|
||||
http_only: bool | None = None
|
||||
secure: bool | None = None
|
||||
same_site: str | None = None
|
||||
value_length: int = 0
|
||||
script_source: str | None = None
|
||||
page_url: str = ""
|
||||
initiator_chain: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ScanResponse(BaseModel):
|
||||
"""Result of a scan."""
|
||||
|
||||
domain: str
|
||||
pages_crawled: int
|
||||
total_cookies: int
|
||||
cookies: list[DiscoveredCookieResponse]
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ValidationRequest(BaseModel):
|
||||
"""Request for consent validation and dark pattern detection."""
|
||||
|
||||
url: str
|
||||
essential_cookie_names: list[str] = Field(default_factory=list)
|
||||
proxy: ProxyRequest | None = None
|
||||
|
||||
|
||||
class ValidationIssueResponse(BaseModel):
|
||||
"""A single validation issue."""
|
||||
|
||||
check: str
|
||||
severity: str
|
||||
message: str
|
||||
recommendation: str
|
||||
details: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DarkPatternIssueResponse(BaseModel):
|
||||
"""A detected dark pattern."""
|
||||
|
||||
pattern: str
|
||||
severity: str
|
||||
message: str
|
||||
recommendation: str
|
||||
details: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ValidationResponse(BaseModel):
|
||||
"""Result of consent validation and dark pattern detection."""
|
||||
|
||||
url: str
|
||||
pre_consent_issues: list[ValidationIssueResponse] = Field(default_factory=list)
|
||||
post_accept_issues: list[ValidationIssueResponse] = Field(default_factory=list)
|
||||
post_reject_issues: list[ValidationIssueResponse] = Field(default_factory=list)
|
||||
dark_pattern_issues: list[DarkPatternIssueResponse] = Field(default_factory=list)
|
||||
banner_found: bool = False
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Application ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_app(): # noqa: ANN201
|
||||
"""Create the scanner FastAPI application."""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from src.crawler import CookieCrawler
|
||||
from src.sitemap import discover_urls
|
||||
|
||||
app = FastAPI(title="CMP Scanner Service", version="0.1.0")
|
||||
settings = ScannerSettings()
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/scan", response_model=ScanResponse)
|
||||
async def run_scan(body: ScanRequest) -> ScanResponse:
|
||||
"""Execute a Playwright crawl and return discovered cookies."""
|
||||
# Discover URLs if none provided
|
||||
urls = body.urls
|
||||
if not urls:
|
||||
try:
|
||||
urls = await discover_urls(
|
||||
body.domain, max_urls=min(body.max_pages, settings.max_pages_per_scan)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("URL discovery failed for %s: %s", body.domain, exc)
|
||||
urls = [f"https://{body.domain}/"]
|
||||
|
||||
if not urls:
|
||||
raise HTTPException(status_code=400, detail="No URLs to scan")
|
||||
|
||||
# Run crawler
|
||||
from src.crawler import ProxyConfig
|
||||
|
||||
proxy_config = None
|
||||
if body.proxy:
|
||||
proxy_config = ProxyConfig(
|
||||
server=body.proxy.server,
|
||||
username=body.proxy.username,
|
||||
password=body.proxy.password,
|
||||
)
|
||||
|
||||
crawler = CookieCrawler(
|
||||
headless=settings.crawler_headless,
|
||||
timeout_ms=settings.crawler_timeout_ms,
|
||||
proxy=proxy_config,
|
||||
)
|
||||
result = await crawler.crawl_site(
|
||||
urls, max_pages=min(body.max_pages, settings.max_pages_per_scan)
|
||||
)
|
||||
|
||||
# Build response
|
||||
cookies = [
|
||||
DiscoveredCookieResponse(
|
||||
name=c.name,
|
||||
domain=c.domain,
|
||||
storage_type=c.storage_type,
|
||||
path=c.path,
|
||||
expires=c.expires,
|
||||
http_only=c.http_only,
|
||||
secure=c.secure,
|
||||
same_site=c.same_site,
|
||||
value_length=c.value_length,
|
||||
script_source=c.script_source,
|
||||
page_url=c.page_url,
|
||||
initiator_chain=c.initiator_chain,
|
||||
)
|
||||
for c in result.unique_cookies
|
||||
]
|
||||
|
||||
errors = [p.error for p in result.pages if p.error]
|
||||
|
||||
return ScanResponse(
|
||||
domain=result.domain,
|
||||
pages_crawled=len(result.pages),
|
||||
total_cookies=result.total_cookies_found,
|
||||
cookies=cookies,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@app.post("/validate", response_model=ValidationResponse)
|
||||
async def run_validation(body: ValidationRequest) -> ValidationResponse:
|
||||
"""Run consent signal validation and dark pattern detection."""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from src.consent_validator import (
|
||||
_is_tracker_request,
|
||||
validate_post_accept,
|
||||
validate_post_reject,
|
||||
validate_pre_consent,
|
||||
)
|
||||
from src.crawler import ProxyConfig
|
||||
from src.dark_pattern_detector import detect_dark_patterns
|
||||
|
||||
response = ValidationResponse(url=body.url)
|
||||
essential_names = set(body.essential_cookie_names)
|
||||
tracker_requests: list[str] = []
|
||||
|
||||
proxy_config = None
|
||||
if body.proxy:
|
||||
proxy_config = ProxyConfig(
|
||||
server=body.proxy.server,
|
||||
username=body.proxy.username,
|
||||
password=body.proxy.password,
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
launch_kwargs: dict = {"headless": settings.crawler_headless}
|
||||
if proxy_config:
|
||||
proxy_opts: dict = {"server": proxy_config.server}
|
||||
if proxy_config.username:
|
||||
proxy_opts["username"] = proxy_config.username
|
||||
if proxy_config.password:
|
||||
proxy_opts["password"] = proxy_config.password
|
||||
launch_kwargs["proxy"] = proxy_opts
|
||||
|
||||
browser = await pw.chromium.launch(**launch_kwargs)
|
||||
try:
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
# Track network requests for tracker detection
|
||||
def _on_request(request) -> None:
|
||||
if _is_tracker_request(request.url):
|
||||
tracker_requests.append(request.url)
|
||||
|
||||
page.on("request", _on_request)
|
||||
|
||||
# ── Pre-consent check ────────────────────────
|
||||
await page.goto(
|
||||
body.url,
|
||||
wait_until="networkidle",
|
||||
timeout=settings.crawler_timeout_ms,
|
||||
)
|
||||
|
||||
pre_issues = await validate_pre_consent(
|
||||
page, context, essential_names, tracker_requests
|
||||
)
|
||||
response.pre_consent_issues = [
|
||||
ValidationIssueResponse(**vars(i)) for i in pre_issues
|
||||
]
|
||||
|
||||
# ── Dark pattern detection ───────────────────
|
||||
dp_result = await detect_dark_patterns(page)
|
||||
response.banner_found = dp_result.banner_found
|
||||
response.dark_pattern_issues = [
|
||||
DarkPatternIssueResponse(**vars(i)) for i in dp_result.issues
|
||||
]
|
||||
|
||||
# ── Post-accept check ────────────────────────
|
||||
# Try to click Accept All
|
||||
accept_selectors = [
|
||||
"button:has-text('Accept All')",
|
||||
"button:has-text('Accept')",
|
||||
"button:has-text('Allow All')",
|
||||
"button:has-text('I Agree')",
|
||||
"[data-action='accept']",
|
||||
]
|
||||
accepted = False
|
||||
for selector in accept_selectors:
|
||||
try:
|
||||
btn = page.locator(selector).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
await page.wait_for_timeout(2000)
|
||||
accepted = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if accepted:
|
||||
tracker_requests.clear()
|
||||
post_accept = await validate_post_accept(page, context)
|
||||
response.post_accept_issues = [
|
||||
ValidationIssueResponse(**vars(i)) for i in post_accept
|
||||
]
|
||||
|
||||
# ── Post-reject check ────────────────────────
|
||||
# Reload and reject
|
||||
await context.clear_cookies()
|
||||
tracker_requests.clear()
|
||||
await page.goto(
|
||||
body.url,
|
||||
wait_until="networkidle",
|
||||
timeout=settings.crawler_timeout_ms,
|
||||
)
|
||||
|
||||
reject_selectors = [
|
||||
"button:has-text('Reject All')",
|
||||
"button:has-text('Reject')",
|
||||
"button:has-text('Decline')",
|
||||
"button:has-text('Deny')",
|
||||
"[data-action='reject']",
|
||||
]
|
||||
rejected = False
|
||||
for selector in reject_selectors:
|
||||
try:
|
||||
btn = page.locator(selector).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
await page.wait_for_timeout(2000)
|
||||
rejected = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if rejected:
|
||||
post_reject_trackers: list[str] = []
|
||||
# Collect any new tracker requests after rejection
|
||||
for req_url in tracker_requests:
|
||||
if _is_tracker_request(req_url):
|
||||
post_reject_trackers.append(req_url)
|
||||
|
||||
post_reject = await validate_post_reject(
|
||||
page, context, essential_names, post_reject_trackers
|
||||
)
|
||||
response.post_reject_issues = [
|
||||
ValidationIssueResponse(**vars(i)) for i in post_reject
|
||||
]
|
||||
|
||||
await context.close()
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
except Exception as exc:
|
||||
response.errors.append(str(exc))
|
||||
logger.warning("Validation failed for %s: %s", body.url, exc)
|
||||
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the scanner service with uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
settings = ScannerSettings()
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
|
||||
uvicorn.run(
|
||||
"src.worker:create_app",
|
||||
factory=True,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
workers=1, # Single worker — Playwright manages its own concurrency
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
apps/scanner/tests/__init__.py
Normal file
0
apps/scanner/tests/__init__.py
Normal file
144
apps/scanner/tests/test_classifier.py
Normal file
144
apps/scanner/tests/test_classifier.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for cookie classification — CMP-21."""
|
||||
|
||||
from src.classifier import (
|
||||
ClassificationResult,
|
||||
KnownPattern,
|
||||
_domain_matches,
|
||||
classify_cookie,
|
||||
)
|
||||
|
||||
# ── Domain matching ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDomainMatching:
|
||||
def test_wildcard_matches_any(self):
|
||||
assert _domain_matches("example.com", "*") is True
|
||||
|
||||
def test_exact_match(self):
|
||||
assert _domain_matches("example.com", "example.com") is True
|
||||
|
||||
def test_exact_no_match(self):
|
||||
assert _domain_matches("other.com", "example.com") is False
|
||||
|
||||
def test_subdomain_match(self):
|
||||
assert _domain_matches("sub.example.com", "example.com") is True
|
||||
|
||||
def test_leading_dot_stripped(self):
|
||||
assert _domain_matches(".example.com", "example.com") is True
|
||||
|
||||
def test_pattern_leading_dot(self):
|
||||
assert _domain_matches("example.com", ".example.com") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _domain_matches("Example.COM", "example.com") is True
|
||||
|
||||
def test_no_partial_match(self):
|
||||
# "notexample.com" should NOT match "example.com"
|
||||
assert _domain_matches("notexample.com", "example.com") is False
|
||||
|
||||
|
||||
# ── Cookie classification ────────────────────────────────────────────
|
||||
|
||||
|
||||
PATTERNS = [
|
||||
KnownPattern(name_pattern="_ga", domain_pattern="*", category="analytics", vendor="Google"),
|
||||
KnownPattern(name_pattern="_ga_*", domain_pattern="*", category="analytics", vendor="Google"),
|
||||
KnownPattern(name_pattern="_gid", domain_pattern="*", category="analytics", vendor="Google"),
|
||||
KnownPattern(
|
||||
name_pattern="_fbp", domain_pattern=".facebook.com", category="marketing", vendor="Meta"
|
||||
),
|
||||
KnownPattern(
|
||||
name_pattern="__cf_bm",
|
||||
domain_pattern="*",
|
||||
category="necessary",
|
||||
vendor="Cloudflare",
|
||||
),
|
||||
KnownPattern(
|
||||
name_pattern="_hj.*",
|
||||
domain_pattern="*",
|
||||
category="analytics",
|
||||
vendor="Hotjar",
|
||||
is_regex=True,
|
||||
),
|
||||
KnownPattern(
|
||||
name_pattern="^_pk_id\\..*",
|
||||
domain_pattern="*",
|
||||
category="analytics",
|
||||
vendor="Matomo",
|
||||
is_regex=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TestClassifyCookie:
|
||||
def test_exact_match(self):
|
||||
result = classify_cookie("_ga", "example.com", PATTERNS)
|
||||
assert result.category == "analytics"
|
||||
assert result.vendor == "Google"
|
||||
assert result.match_source == "exact"
|
||||
|
||||
def test_wildcard_match(self):
|
||||
result = classify_cookie("_ga_ABC123", "example.com", PATTERNS)
|
||||
assert result.category == "analytics"
|
||||
assert result.match_source == "wildcard"
|
||||
|
||||
def test_regex_match(self):
|
||||
result = classify_cookie("_hjSession_123", "example.com", PATTERNS)
|
||||
assert result.category == "analytics"
|
||||
assert result.vendor == "Hotjar"
|
||||
assert result.match_source == "regex"
|
||||
|
||||
def test_regex_matomo(self):
|
||||
result = classify_cookie("_pk_id.1.abc1", "example.com", PATTERNS)
|
||||
assert result.category == "analytics"
|
||||
assert result.vendor == "Matomo"
|
||||
assert result.match_source == "regex"
|
||||
|
||||
def test_domain_specific_match(self):
|
||||
result = classify_cookie("_fbp", "sub.facebook.com", PATTERNS)
|
||||
assert result.category == "marketing"
|
||||
assert result.vendor == "Meta"
|
||||
|
||||
def test_domain_mismatch(self):
|
||||
result = classify_cookie("_fbp", "example.com", PATTERNS)
|
||||
assert result.category is None
|
||||
assert result.match_source == "unmatched"
|
||||
|
||||
def test_unmatched_cookie(self):
|
||||
result = classify_cookie("unknown_cookie", "example.com", PATTERNS)
|
||||
assert result.category is None
|
||||
assert result.match_source == "unmatched"
|
||||
|
||||
def test_necessary_cookie(self):
|
||||
result = classify_cookie("__cf_bm", "example.com", PATTERNS)
|
||||
assert result.category == "necessary"
|
||||
assert result.vendor == "Cloudflare"
|
||||
|
||||
def test_empty_patterns(self):
|
||||
result = classify_cookie("_ga", "example.com", [])
|
||||
assert result.category is None
|
||||
|
||||
def test_exact_takes_priority_over_wildcard(self):
|
||||
"""Exact match should come before wildcard in pattern list."""
|
||||
patterns = [
|
||||
KnownPattern(name_pattern="_ga", domain_pattern="*", category="analytics"),
|
||||
KnownPattern(name_pattern="_ga*", domain_pattern="*", category="marketing"),
|
||||
]
|
||||
result = classify_cookie("_ga", "example.com", patterns)
|
||||
assert result.category == "analytics"
|
||||
assert result.match_source == "exact"
|
||||
|
||||
|
||||
# ── ClassificationResult ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClassificationResult:
|
||||
def test_defaults(self):
|
||||
r = ClassificationResult(category=None)
|
||||
assert r.vendor is None
|
||||
assert r.match_source == "unmatched"
|
||||
|
||||
def test_with_values(self):
|
||||
r = ClassificationResult(category="analytics", vendor="Google", match_source="exact")
|
||||
assert r.category == "analytics"
|
||||
assert r.vendor == "Google"
|
||||
112
apps/scanner/tests/test_consent_validator.py
Normal file
112
apps/scanner/tests/test_consent_validator.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for consent signal validation — mocks Playwright."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.consent_validator import (
|
||||
_is_tracker_request,
|
||||
validate_post_reject,
|
||||
validate_pre_consent,
|
||||
)
|
||||
|
||||
|
||||
class TestIsTrackerRequest:
|
||||
def test_known_tracker(self) -> None:
|
||||
assert _is_tracker_request("https://www.google-analytics.com/collect") is True
|
||||
|
||||
def test_facebook_tracker(self) -> None:
|
||||
assert _is_tracker_request("https://connect.facebook.net/en_US/fbevents.js") is True
|
||||
|
||||
def test_non_tracker(self) -> None:
|
||||
assert _is_tracker_request("https://example.com/style.css") is False
|
||||
|
||||
def test_empty_url(self) -> None:
|
||||
assert _is_tracker_request("") is False
|
||||
|
||||
def test_doubleclick(self) -> None:
|
||||
assert _is_tracker_request("https://ad.doubleclick.net/pixel") is True
|
||||
|
||||
def test_hotjar(self) -> None:
|
||||
assert _is_tracker_request("https://static.hotjar.com/c/hotjar.js") is True
|
||||
|
||||
|
||||
class TestValidatePreConsent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_issues_with_only_essential_cookies(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value={"available": False})
|
||||
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(return_value=[{"name": "session_id", "domain": "example.com"}])
|
||||
|
||||
issues = await validate_pre_consent(page, context, {"session_id"}, [])
|
||||
assert len(issues) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_essential_cookies_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value={"available": False})
|
||||
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(
|
||||
return_value=[
|
||||
{"name": "session_id", "domain": "example.com"},
|
||||
{"name": "_ga", "domain": ".google-analytics.com"},
|
||||
{"name": "_fbp", "domain": ".facebook.com"},
|
||||
]
|
||||
)
|
||||
|
||||
issues = await validate_pre_consent(page, context, {"session_id"}, [])
|
||||
assert len(issues) >= 1
|
||||
cookie_issue = next(i for i in issues if i.check == "pre_consent_cookies")
|
||||
assert cookie_issue.severity == "critical"
|
||||
assert "_ga" in cookie_issue.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracker_requests_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value={"available": False})
|
||||
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(return_value=[])
|
||||
|
||||
tracker_urls = ["https://www.google-analytics.com/collect?v=1"]
|
||||
issues = await validate_pre_consent(page, context, set(), tracker_urls)
|
||||
assert len(issues) >= 1
|
||||
tracker_issue = next(i for i in issues if i.check == "pre_consent_trackers")
|
||||
assert tracker_issue.severity == "critical"
|
||||
|
||||
|
||||
class TestValidatePostReject:
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_rejection(self) -> None:
|
||||
page = AsyncMock()
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(return_value=[])
|
||||
|
||||
issues = await validate_post_reject(page, context, set(), [])
|
||||
assert len(issues) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookies_after_reject_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(
|
||||
return_value=[{"name": "_ga", "domain": ".google-analytics.com"}]
|
||||
)
|
||||
|
||||
issues = await validate_post_reject(page, context, set(), [])
|
||||
assert len(issues) >= 1
|
||||
assert issues[0].check == "post_reject_cookies"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trackers_after_reject_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
context = AsyncMock()
|
||||
context.cookies = AsyncMock(return_value=[])
|
||||
|
||||
tracker_urls = ["https://www.google-analytics.com/collect"]
|
||||
issues = await validate_post_reject(page, context, set(), tracker_urls)
|
||||
assert len(issues) >= 1
|
||||
assert issues[0].check == "post_reject_trackers"
|
||||
440
apps/scanner/tests/test_crawler.py
Normal file
440
apps/scanner/tests/test_crawler.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""Tests for the Playwright cookie crawler — CMP-21.
|
||||
|
||||
These tests mock Playwright to avoid requiring an actual browser.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.crawler import (
|
||||
CookieCrawler,
|
||||
CrawlResult,
|
||||
DiscoveredCookie,
|
||||
SiteCrawlResult,
|
||||
_build_initiator_chain,
|
||||
_get_script_initiator,
|
||||
)
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_page(
|
||||
*,
|
||||
cookies: list[dict] | None = None,
|
||||
ls_items: list[dict] | None = None,
|
||||
ss_items: list[dict] | None = None,
|
||||
):
|
||||
"""Build a mock Playwright Page object."""
|
||||
page = AsyncMock()
|
||||
page.goto = AsyncMock()
|
||||
page.on = MagicMock() # synchronous registration
|
||||
|
||||
# page.evaluate returns different results for localStorage vs sessionStorage
|
||||
eval_results = []
|
||||
eval_results.append(ls_items or [])
|
||||
eval_results.append(ss_items or [])
|
||||
page.evaluate = AsyncMock(side_effect=eval_results)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def _make_mock_context(page, cookies: list[dict] | None = None):
|
||||
"""Build a mock BrowserContext."""
|
||||
context = AsyncMock()
|
||||
context.new_page = AsyncMock(return_value=page)
|
||||
context.cookies = AsyncMock(return_value=cookies or [])
|
||||
context.clear_cookies = AsyncMock()
|
||||
context.close = AsyncMock()
|
||||
return context
|
||||
|
||||
|
||||
def _make_mock_browser(context):
|
||||
"""Build a mock Browser."""
|
||||
browser = AsyncMock()
|
||||
browser.new_context = AsyncMock(return_value=context)
|
||||
browser.close = AsyncMock()
|
||||
return browser
|
||||
|
||||
|
||||
# ── DiscoveredCookie dataclass ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiscoveredCookie:
|
||||
def test_defaults(self):
|
||||
c = DiscoveredCookie(name="_ga", domain="example.com")
|
||||
assert c.storage_type == "cookie"
|
||||
assert c.path is None
|
||||
assert c.expires is None
|
||||
assert c.http_only is None
|
||||
assert c.secure is None
|
||||
assert c.same_site is None
|
||||
assert c.value_length == 0
|
||||
assert c.script_source is None
|
||||
assert c.page_url == ""
|
||||
|
||||
def test_initiator_chain_defaults_to_empty(self):
|
||||
c = DiscoveredCookie(name="_ga", domain="example.com")
|
||||
assert c.initiator_chain == []
|
||||
|
||||
def test_with_all_fields(self):
|
||||
c = DiscoveredCookie(
|
||||
name="_ga",
|
||||
domain=".example.com",
|
||||
storage_type="cookie",
|
||||
path="/",
|
||||
expires=1700000000.0,
|
||||
http_only=True,
|
||||
secure=True,
|
||||
same_site="Lax",
|
||||
value_length=42,
|
||||
script_source="https://cdn.example.com/tracker.js",
|
||||
page_url="https://example.com/",
|
||||
initiator_chain=["https://example.com/", "https://cdn.example.com/tracker.js"],
|
||||
)
|
||||
assert c.http_only is True
|
||||
assert c.value_length == 42
|
||||
assert len(c.initiator_chain) == 2
|
||||
|
||||
|
||||
# ── CrawlResult dataclass ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCrawlResult:
|
||||
def test_defaults(self):
|
||||
r = CrawlResult(url="https://example.com/")
|
||||
assert r.cookies == []
|
||||
assert r.error is None
|
||||
|
||||
def test_with_error(self):
|
||||
r = CrawlResult(url="https://example.com/", error="Timeout")
|
||||
assert r.error == "Timeout"
|
||||
|
||||
|
||||
# ── SiteCrawlResult ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSiteCrawlResult:
|
||||
def test_unique_cookies_deduplicates(self):
|
||||
cookie_a = DiscoveredCookie(name="_ga", domain="example.com", storage_type="cookie")
|
||||
cookie_b = DiscoveredCookie(name="_ga", domain="example.com", storage_type="cookie")
|
||||
cookie_c = DiscoveredCookie(name="_gid", domain="example.com", storage_type="cookie")
|
||||
|
||||
result = SiteCrawlResult(
|
||||
domain="example.com",
|
||||
pages=[
|
||||
CrawlResult(url="https://example.com/", cookies=[cookie_a, cookie_c]),
|
||||
CrawlResult(url="https://example.com/about", cookies=[cookie_b]),
|
||||
],
|
||||
total_cookies_found=3,
|
||||
)
|
||||
|
||||
unique = result.unique_cookies
|
||||
assert len(unique) == 2
|
||||
names = {c.name for c in unique}
|
||||
assert names == {"_ga", "_gid"}
|
||||
|
||||
def test_unique_cookies_separates_storage_types(self):
|
||||
"""Same name in cookie vs localStorage should be separate entries."""
|
||||
cookie = DiscoveredCookie(name="token", domain="example.com", storage_type="cookie")
|
||||
ls = DiscoveredCookie(name="token", domain="example.com", storage_type="local_storage")
|
||||
|
||||
result = SiteCrawlResult(
|
||||
domain="example.com",
|
||||
pages=[CrawlResult(url="https://example.com/", cookies=[cookie, ls])],
|
||||
total_cookies_found=2,
|
||||
)
|
||||
|
||||
assert len(result.unique_cookies) == 2
|
||||
|
||||
def test_empty_pages(self):
|
||||
result = SiteCrawlResult(domain="example.com")
|
||||
assert result.unique_cookies == []
|
||||
|
||||
|
||||
# ── _get_script_initiator ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetScriptInitiator:
|
||||
def test_identifies_js_url(self):
|
||||
request = MagicMock()
|
||||
request.url = "https://cdn.example.com/tracker.js"
|
||||
request.resource_type = "script"
|
||||
request.redirected_from = None
|
||||
|
||||
assert _get_script_initiator(request) == "https://cdn.example.com/tracker.js"
|
||||
|
||||
def test_follows_redirect_chain(self):
|
||||
original = MagicMock()
|
||||
original.url = "https://cdn.example.com/analytics.js"
|
||||
original.resource_type = "script"
|
||||
original.redirected_from = None
|
||||
|
||||
redirect = MagicMock()
|
||||
redirect.url = "https://example.com/track"
|
||||
redirect.resource_type = "fetch"
|
||||
redirect.redirected_from = original
|
||||
|
||||
assert _get_script_initiator(redirect) == "https://cdn.example.com/analytics.js"
|
||||
|
||||
def test_returns_none_for_non_script(self):
|
||||
request = MagicMock()
|
||||
request.url = "https://example.com/image.png"
|
||||
request.resource_type = "image"
|
||||
request.redirected_from = None
|
||||
|
||||
assert _get_script_initiator(request) is None
|
||||
|
||||
def test_handles_javascript_resource_type(self):
|
||||
request = MagicMock()
|
||||
request.url = "https://example.com/bundle"
|
||||
request.resource_type = "javascript"
|
||||
request.redirected_from = None
|
||||
|
||||
assert _get_script_initiator(request) == "https://example.com/bundle"
|
||||
|
||||
def test_handles_circular_redirect(self):
|
||||
"""Should not loop infinitely on circular redirects."""
|
||||
req_a = MagicMock()
|
||||
req_a.url = "https://example.com/a"
|
||||
req_a.resource_type = "fetch"
|
||||
|
||||
req_b = MagicMock()
|
||||
req_b.url = "https://example.com/b"
|
||||
req_b.resource_type = "fetch"
|
||||
|
||||
# Create circular chain
|
||||
req_a.redirected_from = req_b
|
||||
req_b.redirected_from = req_a
|
||||
|
||||
# Should not hang — returns None since neither is a script
|
||||
result = _get_script_initiator(req_a)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _build_initiator_chain ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildInitiatorChain:
|
||||
def test_single_url_no_parent(self):
|
||||
chain = _build_initiator_chain("https://example.com/script.js", {})
|
||||
assert chain == ["https://example.com/script.js"]
|
||||
|
||||
def test_two_level_chain(self):
|
||||
imap = {"https://cdn.example.com/tracker.js": "https://example.com/"}
|
||||
chain = _build_initiator_chain("https://cdn.example.com/tracker.js", imap)
|
||||
assert chain == ["https://example.com/", "https://cdn.example.com/tracker.js"]
|
||||
|
||||
def test_three_level_chain(self):
|
||||
imap = {
|
||||
"https://cdn.example.com/pixel.js": "https://cdn.example.com/gtm.js",
|
||||
"https://cdn.example.com/gtm.js": "https://example.com/",
|
||||
}
|
||||
chain = _build_initiator_chain("https://cdn.example.com/pixel.js", imap)
|
||||
assert chain == [
|
||||
"https://example.com/",
|
||||
"https://cdn.example.com/gtm.js",
|
||||
"https://cdn.example.com/pixel.js",
|
||||
]
|
||||
|
||||
def test_respects_max_depth(self):
|
||||
# Build a chain longer than max_depth
|
||||
imap = {}
|
||||
for i in range(25):
|
||||
imap[f"https://example.com/s{i + 1}.js"] = f"https://example.com/s{i}.js"
|
||||
chain = _build_initiator_chain("https://example.com/s25.js", imap, max_depth=5)
|
||||
# Should be capped: the leaf + 5 parents = 6 entries at most
|
||||
assert len(chain) <= 6
|
||||
|
||||
def test_handles_circular_reference(self):
|
||||
imap = {
|
||||
"https://a.com/a.js": "https://b.com/b.js",
|
||||
"https://b.com/b.js": "https://a.com/a.js",
|
||||
}
|
||||
chain = _build_initiator_chain("https://a.com/a.js", imap)
|
||||
# Should not loop — cycle detected via seen set
|
||||
assert len(chain) == 2
|
||||
|
||||
|
||||
# ── CookieCrawler._crawl_page ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCrawlPage:
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_discovers_browser_cookies(self):
|
||||
cdp_cookies = [
|
||||
{
|
||||
"name": "_ga",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": 1700000000,
|
||||
"httpOnly": False,
|
||||
"secure": True,
|
||||
"sameSite": "Lax",
|
||||
"value": "GA1.2.12345",
|
||||
}
|
||||
]
|
||||
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page, cookies=cdp_cookies)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
assert len(result.cookies) == 1
|
||||
assert result.cookies[0].name == "_ga"
|
||||
assert result.cookies[0].domain == ".example.com"
|
||||
assert result.cookies[0].storage_type == "cookie"
|
||||
assert result.cookies[0].secure is True
|
||||
assert result.cookies[0].value_length == len("GA1.2.12345")
|
||||
assert result.error is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_discovers_local_storage(self):
|
||||
ls_items = [{"name": "theme", "valueLength": 4}]
|
||||
|
||||
page = _make_mock_page(ls_items=ls_items)
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
ls_cookies = [c for c in result.cookies if c.storage_type == "local_storage"]
|
||||
assert len(ls_cookies) == 1
|
||||
assert ls_cookies[0].name == "theme"
|
||||
assert ls_cookies[0].value_length == 4
|
||||
assert ls_cookies[0].domain == "example.com"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_discovers_session_storage(self):
|
||||
ss_items = [{"name": "session_id", "valueLength": 36}]
|
||||
|
||||
page = _make_mock_page(ss_items=ss_items)
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
ss_cookies = [c for c in result.cookies if c.storage_type == "session_storage"]
|
||||
assert len(ss_cookies) == 1
|
||||
assert ss_cookies[0].name == "session_id"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handles_page_error(self):
|
||||
page = _make_mock_page()
|
||||
page.goto = AsyncMock(side_effect=Exception("Navigation timeout"))
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
assert result.error == "Navigation timeout"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_context_closed_after_crawl(self):
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
context.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_context_closed_on_error(self):
|
||||
page = _make_mock_page()
|
||||
page.goto = AsyncMock(side_effect=Exception("fail"))
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
context.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_custom_user_agent(self):
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
crawler = CookieCrawler(user_agent="CMPBot/1.0")
|
||||
await crawler._crawl_page(browser, "https://example.com/")
|
||||
|
||||
browser.new_context.assert_awaited_once()
|
||||
call_kwargs = browser.new_context.call_args[1]
|
||||
assert call_kwargs["user_agent"] == "CMPBot/1.0"
|
||||
|
||||
|
||||
# ── CookieCrawler.crawl_site ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCrawlSite:
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.crawler.async_playwright")
|
||||
async def test_crawls_multiple_pages(self, mock_pw):
|
||||
cdp_cookies = [{"name": "_ga", "domain": ".example.com", "value": "x"}]
|
||||
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page, cookies=cdp_cookies)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
pw_instance = AsyncMock()
|
||||
pw_instance.chromium.launch = AsyncMock(return_value=browser)
|
||||
mock_pw.return_value.__aenter__ = AsyncMock(return_value=pw_instance)
|
||||
mock_pw.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler.crawl_site(["https://example.com/", "https://example.com/about"])
|
||||
|
||||
assert result.domain == "example.com"
|
||||
assert len(result.pages) == 2
|
||||
assert result.total_cookies_found >= 2
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.crawler.async_playwright")
|
||||
async def test_respects_max_pages(self, mock_pw):
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
pw_instance = AsyncMock()
|
||||
pw_instance.chromium.launch = AsyncMock(return_value=browser)
|
||||
mock_pw.return_value.__aenter__ = AsyncMock(return_value=pw_instance)
|
||||
mock_pw.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
urls = [f"https://example.com/page{i}" for i in range(10)]
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler.crawl_site(urls, max_pages=3)
|
||||
|
||||
assert len(result.pages) == 3
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_empty_urls(self):
|
||||
crawler = CookieCrawler()
|
||||
result = await crawler.crawl_site([])
|
||||
|
||||
assert result.domain == ""
|
||||
assert result.pages == []
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.crawler.async_playwright")
|
||||
async def test_browser_closed_after_crawl(self, mock_pw):
|
||||
page = _make_mock_page()
|
||||
context = _make_mock_context(page)
|
||||
browser = _make_mock_browser(context)
|
||||
|
||||
pw_instance = AsyncMock()
|
||||
pw_instance.chromium.launch = AsyncMock(return_value=browser)
|
||||
mock_pw.return_value.__aenter__ = AsyncMock(return_value=pw_instance)
|
||||
mock_pw.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
crawler = CookieCrawler()
|
||||
await crawler.crawl_site(["https://example.com/"])
|
||||
|
||||
browser.close.assert_awaited_once()
|
||||
100
apps/scanner/tests/test_crawler_proxy.py
Normal file
100
apps/scanner/tests/test_crawler_proxy.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Tests for crawler proxy configuration.
|
||||
|
||||
Mocks Playwright to avoid requiring an actual browser installation.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.crawler import CookieCrawler, ProxyConfig
|
||||
|
||||
|
||||
class TestProxyConfig:
|
||||
"""Tests for ProxyConfig dataclass."""
|
||||
|
||||
def test_proxy_config_creation(self) -> None:
|
||||
proxy = ProxyConfig(server="http://proxy.example.com:8080")
|
||||
assert proxy.server == "http://proxy.example.com:8080"
|
||||
assert proxy.username is None
|
||||
assert proxy.password is None
|
||||
|
||||
def test_proxy_config_with_auth(self) -> None:
|
||||
proxy = ProxyConfig(
|
||||
server="http://proxy.example.com:8080",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
assert proxy.username == "user"
|
||||
assert proxy.password == "pass"
|
||||
|
||||
|
||||
class TestCookieCrawlerProxy:
|
||||
"""Tests for CookieCrawler proxy support."""
|
||||
|
||||
def test_crawler_without_proxy(self) -> None:
|
||||
crawler = CookieCrawler(headless=True)
|
||||
assert crawler._proxy is None
|
||||
|
||||
def test_crawler_with_proxy(self) -> None:
|
||||
proxy = ProxyConfig(server="http://proxy.example.com:8080")
|
||||
crawler = CookieCrawler(headless=True, proxy=proxy)
|
||||
assert crawler._proxy is not None
|
||||
assert crawler._proxy.server == "http://proxy.example.com:8080"
|
||||
|
||||
def test_crawler_with_socks5_proxy(self) -> None:
|
||||
proxy = ProxyConfig(server="socks5://proxy.example.com:1080")
|
||||
crawler = CookieCrawler(headless=True, proxy=proxy)
|
||||
assert crawler._proxy.server == "socks5://proxy.example.com:1080"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_passes_proxy_to_browser(self) -> None:
|
||||
"""Verify that proxy config is passed to Playwright launch."""
|
||||
proxy = ProxyConfig(
|
||||
server="http://proxy.example.com:8080",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
crawler = CookieCrawler(headless=True, proxy=proxy)
|
||||
|
||||
mock_browser = AsyncMock()
|
||||
mock_browser.close = AsyncMock()
|
||||
|
||||
mock_pw = MagicMock()
|
||||
mock_pw.chromium.launch = AsyncMock(return_value=mock_browser)
|
||||
|
||||
mock_context_manager = AsyncMock()
|
||||
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_pw)
|
||||
mock_context_manager.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("src.crawler.async_playwright", return_value=mock_context_manager):
|
||||
await crawler.crawl_site(["https://example.com/"], max_pages=1)
|
||||
|
||||
# Verify proxy was passed to browser launch
|
||||
mock_pw.chromium.launch.assert_called_once()
|
||||
call_kwargs = mock_pw.chromium.launch.call_args[1]
|
||||
assert "proxy" in call_kwargs
|
||||
assert call_kwargs["proxy"]["server"] == "http://proxy.example.com:8080"
|
||||
assert call_kwargs["proxy"]["username"] == "user"
|
||||
assert call_kwargs["proxy"]["password"] == "pass"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crawl_without_proxy_omits_proxy_kwarg(self) -> None:
|
||||
"""Verify that no proxy is passed when none is configured."""
|
||||
crawler = CookieCrawler(headless=True)
|
||||
|
||||
mock_browser = AsyncMock()
|
||||
mock_browser.close = AsyncMock()
|
||||
|
||||
mock_pw = MagicMock()
|
||||
mock_pw.chromium.launch = AsyncMock(return_value=mock_browser)
|
||||
|
||||
mock_context_manager = AsyncMock()
|
||||
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_pw)
|
||||
mock_context_manager.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("src.crawler.async_playwright", return_value=mock_context_manager):
|
||||
await crawler.crawl_site(["https://example.com/"], max_pages=1)
|
||||
|
||||
call_kwargs = mock_pw.chromium.launch.call_args[1]
|
||||
assert "proxy" not in call_kwargs
|
||||
153
apps/scanner/tests/test_dark_pattern_detector.py
Normal file
153
apps/scanner/tests/test_dark_pattern_detector.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for dark pattern detection — mocks Playwright."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.dark_pattern_detector import (
|
||||
check_button_prominence,
|
||||
check_cookie_wall,
|
||||
check_pre_ticked_boxes,
|
||||
detect_dark_patterns,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckButtonProminence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_accept_button_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
issues = await check_button_prominence(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_reject_button_flagged(self) -> None:
|
||||
# Accept button visible, reject not found
|
||||
accept_el = AsyncMock()
|
||||
accept_el.is_visible = AsyncMock(return_value=True)
|
||||
accept_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 200,
|
||||
"height": 40,
|
||||
"area": 8000,
|
||||
"backgroundColor": "rgb(37, 99, 235)",
|
||||
"color": "rgb(255, 255, 255)",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600",
|
||||
"padding": "8px 16px",
|
||||
"text": "Accept All",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _mock_query(selector):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# First batch of calls = accept selectors, return button
|
||||
# Remaining calls = reject selectors, return empty
|
||||
if "Accept" in selector or "Allow" in selector or "accept" in selector:
|
||||
return [accept_el]
|
||||
return []
|
||||
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = _mock_query
|
||||
|
||||
issues = await check_button_prominence(page)
|
||||
assert any(i.pattern == "missing_reject_button" for i in issues)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unequal_button_size_flagged(self) -> None:
|
||||
accept_el = AsyncMock()
|
||||
accept_el.is_visible = AsyncMock(return_value=True)
|
||||
accept_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 300,
|
||||
"height": 50,
|
||||
"area": 15000,
|
||||
"fontSize": 18,
|
||||
"fontWeight": "700",
|
||||
"text": "Accept All",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
reject_el = AsyncMock()
|
||||
reject_el.is_visible = AsyncMock(return_value=True)
|
||||
reject_el.evaluate = AsyncMock(
|
||||
return_value={
|
||||
"width": 100,
|
||||
"height": 30,
|
||||
"area": 3000,
|
||||
"fontSize": 12,
|
||||
"fontWeight": "400",
|
||||
"text": "Reject",
|
||||
"visible": True,
|
||||
}
|
||||
)
|
||||
|
||||
async def _mock_query(selector):
|
||||
if "Accept" in selector or "Allow" in selector or "accept" in selector:
|
||||
return [accept_el]
|
||||
if "Reject" in selector or "Decline" in selector or "reject" in selector:
|
||||
return [reject_el]
|
||||
return []
|
||||
|
||||
page = AsyncMock()
|
||||
page.query_selector_all = _mock_query
|
||||
|
||||
issues = await check_button_prominence(page)
|
||||
assert any(i.pattern == "unequal_button_size" for i in issues)
|
||||
|
||||
|
||||
class TestCheckPreTickedBoxes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pre_ticked_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=[])
|
||||
issues = await check_pre_ticked_boxes(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_ticked_non_essential_flagged(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(
|
||||
return_value=[
|
||||
{"name": "analytics", "label": "Analytics Cookies"},
|
||||
{"name": "marketing", "label": "Marketing Cookies"},
|
||||
]
|
||||
)
|
||||
issues = await check_pre_ticked_boxes(page)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].pattern == "pre_ticked_checkboxes"
|
||||
assert issues[0].severity == "critical"
|
||||
|
||||
|
||||
class TestCheckCookieWall:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_wall_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=False)
|
||||
issues = await check_cookie_wall(page)
|
||||
assert issues == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wall_detected(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=True)
|
||||
issues = await check_cookie_wall(page)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].pattern == "cookie_wall"
|
||||
assert issues[0].severity == "critical"
|
||||
|
||||
|
||||
class TestDetectDarkPatterns:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_banner_returns_empty(self) -> None:
|
||||
page = AsyncMock()
|
||||
page.url = "https://example.com/"
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
result = await detect_dark_patterns(page)
|
||||
assert result.banner_found is False
|
||||
assert result.issues == []
|
||||
275
apps/scanner/tests/test_sitemap.py
Normal file
275
apps/scanner/tests/test_sitemap.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for sitemap URL discovery — CMP-21."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.sitemap import _fetch_sitemap, _find_sitemap_in_robots, discover_urls
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_response(status_code: int = 200, text: str = "") -> httpx.Response:
|
||||
"""Build a fake httpx.Response."""
|
||||
return httpx.Response(
|
||||
status_code=status_code, text=text, request=httpx.Request("GET", "http://x")
|
||||
)
|
||||
|
||||
|
||||
SITEMAP_XML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://example.com/page1</loc></url>
|
||||
<url><loc>https://example.com/page2</loc></url>
|
||||
<url><loc>https://example.com/page3</loc></url>
|
||||
</urlset>
|
||||
"""
|
||||
|
||||
SITEMAP_INDEX_XML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://example.com/sitemap-main.xml</loc></sitemap>
|
||||
<sitemap><loc>https://example.com/sitemap-blog.xml</loc></sitemap>
|
||||
</sitemapindex>
|
||||
"""
|
||||
|
||||
CHILD_SITEMAP_XML = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://example.com/blog/post1</loc></url>
|
||||
<url><loc>https://example.com/blog/post2</loc></url>
|
||||
</urlset>
|
||||
"""
|
||||
|
||||
ROBOTS_TXT_WITH_SITEMAP = """\
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
Sitemap: https://example.com/custom-sitemap.xml
|
||||
"""
|
||||
|
||||
ROBOTS_TXT_NO_SITEMAP = """\
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
"""
|
||||
|
||||
|
||||
# ── _fetch_sitemap ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFetchSitemap:
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_parses_regular_sitemap(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, SITEMAP_XML))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert urls == [
|
||||
"https://example.com/page1",
|
||||
"https://example.com/page2",
|
||||
"https://example.com/page3",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_respects_max_urls(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, SITEMAP_XML))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 2)
|
||||
|
||||
assert len(urls) == 2
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handles_sitemap_index(self):
|
||||
"""Sitemap index should recursively fetch child sitemaps."""
|
||||
responses = {
|
||||
"https://example.com/sitemap.xml": _make_response(200, SITEMAP_INDEX_XML),
|
||||
"https://example.com/sitemap-main.xml": _make_response(200, SITEMAP_XML),
|
||||
"https://example.com/sitemap-blog.xml": _make_response(200, CHILD_SITEMAP_XML),
|
||||
}
|
||||
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(side_effect=lambda url: responses[url])
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert len(urls) == 5
|
||||
assert "https://example.com/page1" in urls
|
||||
assert "https://example.com/blog/post1" in urls
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_sitemap_index_respects_max_urls(self):
|
||||
"""Should stop fetching child sitemaps once max_urls is reached."""
|
||||
responses = {
|
||||
"https://example.com/sitemap.xml": _make_response(200, SITEMAP_INDEX_XML),
|
||||
"https://example.com/sitemap-main.xml": _make_response(200, SITEMAP_XML),
|
||||
"https://example.com/sitemap-blog.xml": _make_response(200, CHILD_SITEMAP_XML),
|
||||
}
|
||||
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(side_effect=lambda url: responses[url])
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 3)
|
||||
|
||||
assert len(urls) == 3
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_empty_on_404(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(404))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert urls == []
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_empty_on_invalid_xml(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, "not xml at all"))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert urls == []
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_empty_on_network_error(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert urls == []
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_empty_urlset(self):
|
||||
empty_sitemap = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
</urlset>
|
||||
"""
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, empty_sitemap))
|
||||
|
||||
urls = await _fetch_sitemap(client, "https://example.com/sitemap.xml", 50)
|
||||
|
||||
assert urls == []
|
||||
|
||||
|
||||
# ── _find_sitemap_in_robots ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindSitemapInRobots:
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_finds_sitemap_directive(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, ROBOTS_TXT_WITH_SITEMAP))
|
||||
|
||||
url = await _find_sitemap_in_robots(client, "https://example.com/robots.txt")
|
||||
|
||||
assert url == "https://example.com/custom-sitemap.xml"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_none_when_no_directive(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, ROBOTS_TXT_NO_SITEMAP))
|
||||
|
||||
url = await _find_sitemap_in_robots(client, "https://example.com/robots.txt")
|
||||
|
||||
assert url is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_none_on_404(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(404))
|
||||
|
||||
url = await _find_sitemap_in_robots(client, "https://example.com/robots.txt")
|
||||
|
||||
assert url is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_returns_none_on_network_error(self):
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
url = await _find_sitemap_in_robots(client, "https://example.com/robots.txt")
|
||||
|
||||
assert url is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_case_insensitive_directive(self):
|
||||
robots = "User-agent: *\nsITEMAP: https://example.com/sm.xml\n"
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_make_response(200, robots))
|
||||
|
||||
url = await _find_sitemap_in_robots(client, "https://example.com/robots.txt")
|
||||
|
||||
assert url == "https://example.com/sm.xml"
|
||||
|
||||
|
||||
# ── discover_urls ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiscoverUrls:
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.sitemap._fetch_sitemap")
|
||||
@patch("src.sitemap._find_sitemap_in_robots")
|
||||
async def test_returns_sitemap_urls(self, mock_robots, mock_sitemap):
|
||||
"""Should return URLs from /sitemap.xml when available."""
|
||||
mock_sitemap.return_value = [
|
||||
"https://example.com/page1",
|
||||
"https://example.com/page2",
|
||||
]
|
||||
|
||||
urls = await discover_urls("example.com")
|
||||
|
||||
assert urls == ["https://example.com/page1", "https://example.com/page2"]
|
||||
mock_robots.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.sitemap._fetch_sitemap")
|
||||
@patch("src.sitemap._find_sitemap_in_robots")
|
||||
async def test_falls_back_to_robots_txt(self, mock_robots, mock_sitemap):
|
||||
"""When sitemap.xml returns nothing, should try robots.txt."""
|
||||
mock_sitemap.side_effect = [[], ["https://example.com/from-robots"]]
|
||||
mock_robots.return_value = "https://example.com/alt-sitemap.xml"
|
||||
|
||||
urls = await discover_urls("example.com")
|
||||
|
||||
assert urls == ["https://example.com/from-robots"]
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.sitemap._fetch_sitemap")
|
||||
@patch("src.sitemap._find_sitemap_in_robots")
|
||||
async def test_falls_back_to_default_paths(self, mock_robots, mock_sitemap):
|
||||
"""When no sitemap exists, should return default paths."""
|
||||
mock_sitemap.return_value = []
|
||||
mock_robots.return_value = None
|
||||
|
||||
urls = await discover_urls("example.com")
|
||||
|
||||
assert "https://example.com/" in urls
|
||||
assert "https://example.com/privacy" in urls
|
||||
assert "https://example.com/cookie-policy" in urls
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.sitemap._fetch_sitemap")
|
||||
@patch("src.sitemap._find_sitemap_in_robots")
|
||||
async def test_respects_max_urls(self, mock_robots, mock_sitemap):
|
||||
many_urls = [f"https://example.com/page{i}" for i in range(100)]
|
||||
mock_sitemap.return_value = many_urls
|
||||
|
||||
urls = await discover_urls("example.com", max_urls=5)
|
||||
|
||||
assert len(urls) == 5
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@patch("src.sitemap._fetch_sitemap")
|
||||
@patch("src.sitemap._find_sitemap_in_robots")
|
||||
async def test_default_paths_respect_max_urls(self, mock_robots, mock_sitemap):
|
||||
mock_sitemap.return_value = []
|
||||
mock_robots.return_value = None
|
||||
|
||||
urls = await discover_urls("example.com", max_urls=3)
|
||||
|
||||
assert len(urls) == 3
|
||||
122
apps/scanner/tests/test_worker.py
Normal file
122
apps/scanner/tests/test_worker.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Tests for the scanner HTTP service."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.worker import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client for the scanner app."""
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_health_endpoint(client):
|
||||
"""Health endpoint returns ok."""
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@patch("src.sitemap.discover_urls", new_callable=AsyncMock)
|
||||
@patch("src.crawler.CookieCrawler.crawl_site", new_callable=AsyncMock)
|
||||
def test_scan_endpoint_with_domain(mock_crawl, mock_discover, client):
|
||||
"""POST /scan with just a domain discovers URLs and crawls."""
|
||||
from src.crawler import CrawlResult, DiscoveredCookie, SiteCrawlResult
|
||||
|
||||
mock_discover.return_value = ["https://example.com/"]
|
||||
mock_crawl.return_value = SiteCrawlResult(
|
||||
domain="example.com",
|
||||
pages=[
|
||||
CrawlResult(
|
||||
url="https://example.com/",
|
||||
cookies=[
|
||||
DiscoveredCookie(
|
||||
name="_ga",
|
||||
domain=".example.com",
|
||||
storage_type="cookie",
|
||||
page_url="https://example.com/",
|
||||
value_length=30,
|
||||
),
|
||||
DiscoveredCookie(
|
||||
name="session_id",
|
||||
domain="example.com",
|
||||
storage_type="cookie",
|
||||
page_url="https://example.com/",
|
||||
value_length=36,
|
||||
http_only=True,
|
||||
secure=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
total_cookies_found=2,
|
||||
)
|
||||
|
||||
resp = client.post("/scan", json={"domain": "example.com", "max_pages": 5})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["domain"] == "example.com"
|
||||
assert data["pages_crawled"] == 1
|
||||
assert data["total_cookies"] == 2
|
||||
assert len(data["cookies"]) == 2
|
||||
assert data["cookies"][0]["name"] == "_ga"
|
||||
assert data["cookies"][1]["name"] == "session_id"
|
||||
assert data["cookies"][1]["secure"] is True
|
||||
|
||||
|
||||
@patch("src.crawler.CookieCrawler.crawl_site", new_callable=AsyncMock)
|
||||
def test_scan_endpoint_with_urls(mock_crawl, client):
|
||||
"""POST /scan with explicit URLs skips URL discovery."""
|
||||
from src.crawler import CrawlResult, SiteCrawlResult
|
||||
|
||||
mock_crawl.return_value = SiteCrawlResult(
|
||||
domain="example.com",
|
||||
pages=[CrawlResult(url="https://example.com/about", cookies=[])],
|
||||
total_cookies_found=0,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/scan",
|
||||
json={
|
||||
"domain": "example.com",
|
||||
"urls": ["https://example.com/about"],
|
||||
"max_pages": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pages_crawled"] == 1
|
||||
assert data["cookies"] == []
|
||||
|
||||
|
||||
@patch("src.sitemap.discover_urls", new_callable=AsyncMock)
|
||||
@patch("src.crawler.CookieCrawler.crawl_site", new_callable=AsyncMock)
|
||||
def test_scan_endpoint_with_errors(mock_crawl, mock_discover, client):
|
||||
"""Scan results include page errors."""
|
||||
from src.crawler import CrawlResult, SiteCrawlResult
|
||||
|
||||
mock_discover.return_value = ["https://example.com/"]
|
||||
mock_crawl.return_value = SiteCrawlResult(
|
||||
domain="example.com",
|
||||
pages=[
|
||||
CrawlResult(url="https://example.com/", cookies=[], error="Timeout"),
|
||||
],
|
||||
total_cookies_found=0,
|
||||
)
|
||||
|
||||
resp = client.post("/scan", json={"domain": "example.com"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["errors"] == ["Timeout"]
|
||||
|
||||
|
||||
def test_scan_request_validation(client):
|
||||
"""Missing domain returns 422."""
|
||||
resp = client.post("/scan", json={})
|
||||
assert resp.status_code == 422
|
||||
812
apps/scanner/uv.lock
generated
Normal file
812
apps/scanner/uv.lock
generated
Normal file
@@ -0,0 +1,812 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmp-scanner"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "playwright" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "playwright", specifier = ">=1.49,<2" },
|
||||
{ name = "pydantic", specifier = ">=2.0,<3" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0,<3" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0,<9" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24,<1" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0,<7" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.135.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.58.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user