From b7746acbc9a5194ac2700f4d1c5b7d7ca9112fa9 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 13:50:41 -0300 Subject: [PATCH 01/14] Add project health hardening runbook --- docs/project-health-hardening.md | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/project-health-hardening.md diff --git a/docs/project-health-hardening.md b/docs/project-health-hardening.md new file mode 100644 index 0000000..ddefd1e --- /dev/null +++ b/docs/project-health-hardening.md @@ -0,0 +1,93 @@ +# Project Health Hardening Runbook + +This document keeps the project-health hardening workflow restartable after a +pause. GitHub issue #44 is the source of truth for current status. + +## Source Of Truth + +- Tracking issue: https://github.com/wilsonfreitas/python-bcb/issues/44 +- Milestone: `Project health hardening` +- Coordination branch: `project-health-hardening` + +## Branch Strategy + +Do not work from `main` for this initiative. + +Each implementation issue should use its own branch created from +`project-health-hardening`: + +```bash +git switch project-health-hardening +git pull +git switch -c hardening/- +``` + +Merge path: + +```text +implementation branch -> project-health-hardening -> main +``` + +Target pull requests for implementation issues to `project-health-hardening`, +not `main`. + +## Execution Order + +Follow the order in issue #44 unless there is a concrete reason to reorder. + +Recommended order: + +1. Fix the deterministic integration failure. +2. Add or adjust tests around current behavior before broad refactors. +3. Centralize and normalize HTTP and OData error handling. +4. Harden OData query serialization. +5. Align sync and async behavior. +6. Improve validation and parsing resilience. +7. Clean docs and examples. +8. Raise CI, coverage, lint, and dependency gates last. + +## Working An Issue + +For each implementation issue: + +1. Read issue #44 and the specific implementation issue. +2. Start from an up-to-date `project-health-hardening`. +3. Create a focused branch named `hardening/-`. +4. Keep changes scoped to that issue. +5. Run the checks listed in the issue. +6. Push the branch and open a PR against `project-health-hardening`. +7. After merge, check off the item in issue #44. + +## Default Checks + +Run the targeted tests for the issue, then use the standard project checks: + +```bash +uv run pytest -m "not integration" +uv run ruff check bcb/ tests/ +uv run ruff format --check bcb/ tests/ +uv run mypy bcb/ +``` + +For integration-test issues, also run: + +```bash +uv run pytest -m integration +``` + +For coverage-focused issues, also run: + +```bash +uv run pytest --cov=bcb --cov-report=term-missing -m "not integration" +``` + +## Resume Prompt + +Use this prompt after the project has been idle: + +```text +Read issue #44 and start the next unchecked ticket. Work from +`project-health-hardening`, create a branch named +`hardening/-`, keep the work scoped to that issue, +and target the PR back to `project-health-hardening`. +``` From a007909500987d5acc67dcfbd097846305523e50 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 13:55:20 -0300 Subject: [PATCH 02/14] Fix currency integration missing-symbol expectation --- tests/integration/test_currency.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_currency.py b/tests/integration/test_currency.py index e31e7f4..c218b2d 100644 --- a/tests/integration/test_currency.py +++ b/tests/integration/test_currency.py @@ -23,8 +23,8 @@ def test_currency_get_symbol(): end_date = datetime.strptime("2020-12-05", "%Y-%m-%d") x = currency._get_symbol("USD", start_date, end_date) assert isinstance(x, pd.DataFrame) - x = currency._get_symbol("ZAR", start_date, end_date) - assert x is None + with pytest.raises(CurrencyNotFoundError): + currency._get_symbol("ZAR", start_date, end_date) x = currency.get("USD", start_date, end_date) assert isinstance(x, pd.DataFrame) with pytest.raises(CurrencyNotFoundError): From a9473cec687e864803590d8bc369f5bb9cb7641e Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 15:41:48 -0300 Subject: [PATCH 03/14] Normalize HTTP error handling (#46) --- bcb/currency.py | 166 ++++++++++++-------------------- bcb/http.py | 70 +++++++++++++- bcb/odata/framework.py | 68 +++++++++++-- bcb/sgs/__init__.py | 84 ++++++++-------- tests/test_async.py | 35 +++++++ tests/test_currency_negative.py | 33 +++++++ tests/test_http.py | 84 ++++++++++++++++ tests/test_odata.py | 53 ++++++++++ tests/test_sgs_negative.py | 21 ++++ 9 files changed, 461 insertions(+), 153 deletions(-) create mode 100644 tests/test_http.py diff --git a/bcb/currency.py b/bcb/currency.py index 0c57b14..eeac772 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -6,25 +6,23 @@ import threading from datetime import date, timedelta from io import BytesIO, StringIO -from typing import TYPE_CHECKING, Dict, List, Literal, NamedTuple, Union, overload +from typing import Dict, List, Literal, NamedTuple, Union, overload from urllib.parse import urlencode +import httpx import numpy as np import pandas as pd from lxml import html -from bcb.http import _CLIENT, _ASYNC_CLIENT -from bcb.exceptions import ( - BCBAPIError, - BCBAPINotFoundError, - BCBRateLimitError, - CurrencyNotFoundError, +from bcb.http import ( + _CLIENT, + _ASYNC_CLIENT, + raise_for_request_error, + raise_for_status, ) +from bcb.exceptions import BCBAPIError, CurrencyNotFoundError from bcb.utils import Date, DateInput -if TYPE_CHECKING: - import httpx - logger = logging.getLogger(__name__) """ @@ -165,28 +163,18 @@ def _currency_id_list( "method=exibeFormularioConsultaBoletim" ) logger.debug(f"Fetching currency ID list from {url1}") - res = _CLIENT.get(url1) + try: + res = _CLIENT.get(url1) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context="Currency ID list") logger.debug( f"Currency ID list response: status={res.status_code}, length={len(res.content)}" ) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - "BCB API endpoint not found (404)", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - msg = f"BCB API Request error, status code = {res.status_code}" - raise BCBAPIError(msg, res.status_code) + raise_for_status( + res, + context="Currency ID list", + not_found_message="BCB API endpoint not found (404)", + ) doc = html.parse(BytesIO(res.content)).getroot() xpath = "//select[@name='ChkMoeda']/option" @@ -238,10 +226,10 @@ def _get_valid_currency_list( logger.debug(f"Fetching currency list from {url2}") try: res = _CLIENT.get(url2) - except Exception as ex: + except httpx.HTTPError as ex: # Connection error: retry same date up to 3 times if n >= 3: - raise ex + raise_for_request_error(ex, context="Currency list") logger.warning( f"Connection error fetching {url2}, retrying (attempt {n + 1}/3)" ) @@ -252,12 +240,12 @@ def _get_valid_currency_list( ) if res.status_code == 200: return res - else: - # Non-200 response (file not found for date): roll back to previous day - logger.debug( - f"Currency list not found for {_date}, rolling back to previous day" - ) - return _get_valid_currency_list(_date - timedelta(1), 0, max_rollback) + if res.status_code == 429 or res.status_code >= 500: + raise_for_status(res, context="Currency list") + + # Non-200 response (file not found for date): roll back to previous day + logger.debug(f"Currency list not found for {_date}, rolling back to previous day") + return _get_valid_currency_list(_date - timedelta(1), 0, max_rollback) def get_currency_list( @@ -347,13 +335,16 @@ def _fetch_symbol_response( cid = _get_currency_id(symbol) # Raises CurrencyNotFoundError if not found url = _currency_url(cid, start_date, end_date) logger.debug(f"Fetching currency data for {symbol} from {url.split('?')[0]}") - res = _CLIENT.get(url) + try: + res = _CLIENT.get(url) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context=f"Currency data for {symbol}") logger.debug( f"Currency data response: status={res.status_code}, length={len(res.content)}" ) # Handle HTML error response (e.g., no data for date range) - if res.headers["Content-Type"].startswith("text/html"): + if res.headers.get("Content-Type", "").startswith("text/html"): doc = html.parse(BytesIO(res.content)).getroot() xpath = "//div[@class='msgErro']" elm = doc.xpath(xpath)[0] @@ -363,27 +354,11 @@ def _fetch_symbol_response( msg = f"BCB API returned error: {x} - {symbol}" raise BCBAPIError(msg, status_code=400) - # Handle HTTP error responses - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - f"Currency data not found for {symbol}", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - raise BCBAPIError( - f"BCB API request failed with status {res.status_code}", - status_code=res.status_code, - ) + raise_for_status( + res, + context=f"Currency data for {symbol}", + not_found_message=f"Currency data not found for {symbol}", + ) return res @@ -689,25 +664,15 @@ async def _async_currency_id_list( "https://ptax.bcb.gov.br/ptax_internet/consultaBoletim.do?" "method=exibeFormularioConsultaBoletim" ) - res = await _ASYNC_CLIENT.get(url1) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - "BCB API endpoint not found (404)", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - msg = f"BCB API Request error, status code = {res.status_code}" - raise BCBAPIError(msg, res.status_code) + try: + res = await _ASYNC_CLIENT.get(url1) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context="Currency ID list") + raise_for_status( + res, + context="Currency ID list", + not_found_message="BCB API endpoint not found (404)", + ) doc = html.parse(BytesIO(res.content)).getroot() xpath = "//select[@name='ChkMoeda']/option" @@ -732,17 +697,16 @@ async def _async_get_valid_currency_list( url2 = f"https://www4.bcb.gov.br/Download/fechamento/M{_date:%Y%m%d}.csv" try: res = await _ASYNC_CLIENT.get(url2) - except Exception as ex: + except httpx.HTTPError as ex: if n >= 3: - raise ex + raise_for_request_error(ex, context="Currency list") return await _async_get_valid_currency_list(_date, n + 1, max_rollback) if res.status_code == 200: return res - else: - return await _async_get_valid_currency_list( - _date - timedelta(1), 0, max_rollback - ) + if res.status_code == 429 or res.status_code >= 500: + raise_for_status(res, context="Currency list") + return await _async_get_valid_currency_list(_date - timedelta(1), 0, max_rollback) async def _async_get_currency_list( @@ -794,9 +758,12 @@ async def _async_fetch_symbol_response( """Async version of _fetch_symbol_response().""" cid = await _async_get_currency_id(symbol) url = _currency_url(cid, start_date, end_date) - res = await _ASYNC_CLIENT.get(url) + try: + res = await _ASYNC_CLIENT.get(url) + except httpx.HTTPError as ex: + raise_for_request_error(ex, context=f"Currency data for {symbol}") - if res.headers["Content-Type"].startswith("text/html"): + if res.headers.get("Content-Type", "").startswith("text/html"): doc = html.parse(BytesIO(res.content)).getroot() xpath = "//div[@class='msgErro']" elm = doc.xpath(xpath)[0] @@ -806,26 +773,11 @@ async def _async_fetch_symbol_response( msg = f"BCB API returned error: {x} - {symbol}" raise BCBAPIError(msg, status_code=400) - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code == 404: - raise BCBAPINotFoundError( - f"Currency data not found for {symbol}", - status_code=404, - ) - if res.status_code >= 500: - raise BCBAPIError( - f"BCB API server error (status {res.status_code})", - status_code=res.status_code, - ) - if res.status_code != 200: - raise BCBAPIError( - f"BCB API request failed with status {res.status_code}", - status_code=res.status_code, - ) + raise_for_status( + res, + context=f"Currency data for {symbol}", + not_found_message=f"Currency data not found for {symbol}", + ) return res diff --git a/bcb/http.py b/bcb/http.py index a62b538..07b3a15 100644 --- a/bcb/http.py +++ b/bcb/http.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Callable, TypeVar +from typing import Callable, NoReturn, TypeVar import httpx from tenacity import ( @@ -11,6 +11,13 @@ wait_exponential, ) +from bcb.exceptions import ( + BCBAPIError, + BCBAPINotFoundError, + BCBAPIServerError, + BCBRateLimitError, +) + # Default timeout for all HTTP requests (seconds) DEFAULT_TIMEOUT = 30.0 @@ -82,6 +89,67 @@ def close_async_client() -> None: T = TypeVar("T") +def _raise_error( + error_cls: type[Exception], + message: str, + status_code: int, +) -> NoReturn: + """Raise project exceptions with or without an HTTP status constructor.""" + if issubclass(error_cls, BCBAPIError): + raise error_cls(message, status_code) + raise error_cls(message) + + +def raise_for_status( + response: httpx.Response, + *, + context: str, + expected_status: int | tuple[int, ...] = 200, + error_cls: type[Exception] = BCBAPIError, + not_found_cls: type[Exception] = BCBAPINotFoundError, + rate_limit_cls: type[Exception] = BCBRateLimitError, + server_error_cls: type[Exception] = BCBAPIServerError, + rate_limit_message: str | None = None, + not_found_message: str | None = None, + server_error_message: str | None = None, + error_message: str | None = None, +) -> None: + """Raise a consistent project exception for unexpected HTTP statuses.""" + expected = ( + (expected_status,) if isinstance(expected_status, int) else expected_status + ) + status_code = response.status_code + if status_code in expected: + return + + if status_code == 429: + message = ( + rate_limit_message or "BCB API rate limit exceeded. Please try again later." + ) + _raise_error(rate_limit_cls, message, status_code) + if status_code == 404: + message = not_found_message or f"{context} not found (status 404)" + _raise_error(not_found_cls, message, status_code) + if status_code >= 500: + message = ( + server_error_message or f"{context} server error (status {status_code})" + ) + _raise_error(server_error_cls, message, status_code) + + message = error_message or f"{context} request failed with status {status_code}" + _raise_error(error_cls, message, status_code) + + +def raise_for_request_error( + exc: httpx.HTTPError, + *, + context: str, + error_cls: type[Exception] = BCBAPIError, +) -> NoReturn: + """Raise a consistent project exception for HTTP client failures.""" + _raise_error(error_cls, f"{context} request failed: {exc}", status_code=0) + + def with_retry(func: Callable[..., T]) -> Callable[..., T]: """Decorator to add automatic retry with exponential backoff to any function. diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index a9af401..728e83b 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -1,16 +1,17 @@ from __future__ import annotations +import json import logging import threading from io import BytesIO from typing import Any, Optional, Union +from urllib.parse import quote +import httpx from lxml import etree -import json -from urllib.parse import quote from typing_extensions import Self -from bcb.http import _CLIENT, _ASYNC_CLIENT +from bcb.http import _ASYNC_CLIENT, _CLIENT, raise_for_request_error, raise_for_status from bcb.exceptions import ODataError logger = logging.getLogger(__name__) @@ -283,10 +284,23 @@ def __init__(self, url: str) -> None: def _load_document(self) -> None: logger.debug(f"Fetching OData metadata from {self.url}") - res = _CLIENT.get(self.url) + try: + res = _CLIENT.get(self.url) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData metadata {self.url}", error_cls=ODataError + ) logger.debug( f"OData metadata response: status={res.status_code}, length={len(res.content)}" ) + raise_for_status( + res, + context=f"OData metadata {self.url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) self.doc = etree.parse(BytesIO(res.content)) def _parse_entity(self, entity_element: Any, namespace: str) -> ODataEntity: @@ -378,7 +392,20 @@ class ODataService: def __init__(self, url: str) -> None: self.url = url - res = _CLIENT.get(self.url) + try: + res = _CLIENT.get(self.url) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData service {self.url}", error_cls=ODataError + ) + raise_for_status( + res, + context=f"OData service {self.url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) self.api_data: dict[str, Any] = json.loads(res.text) self.endpoints: list[ODataEndPoint] = [ ODataEndPoint(**x) for x in self.api_data["value"] @@ -541,7 +568,21 @@ async def async_text(self) -> str: params["@" + (p.name or "")] = p.format(val) qs = "&".join([f"{quote(k)}={quote(str(v))}" for k, v in params.items()]) headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} - res = await _ASYNC_CLIENT.get(self.odata_url() + "?" + qs, headers=headers) + url = self.odata_url() + try: + res = await _ASYNC_CLIENT.get(url + "?" + qs, headers=headers) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData query {url}", error_cls=ODataError + ) + raise_for_status( + res, + context=f"OData query {url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) return res.text async def async_collect(self) -> Any: @@ -560,10 +601,23 @@ def text(self) -> str: headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} url = self.odata_url() logger.debug(f"Fetching OData query from {url}") - res = _CLIENT.get(url + "?" + qs, headers=headers) + try: + res = _CLIENT.get(url + "?" + qs, headers=headers) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"OData query {url}", error_cls=ODataError + ) logger.debug( f"OData query response: status={res.status_code}, length={len(res.text)}" ) + raise_for_status( + res, + context=f"OData query {url}", + error_cls=ODataError, + not_found_cls=ODataError, + rate_limit_cls=ODataError, + server_error_cls=ODataError, + ) return res.text def show(self) -> None: diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 9604a35..a60e1dc 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -18,10 +18,16 @@ overload, ) +import httpx import pandas as pd -from bcb.http import _CLIENT, _ASYNC_CLIENT -from bcb.exceptions import BCBRateLimitError, SGSError +from bcb.http import ( + _CLIENT, + _ASYNC_CLIENT, + raise_for_request_error, + raise_for_status, +) +from bcb.exceptions import SGSError from bcb.utils import Date, DateInput logger = logging.getLogger(__name__) @@ -178,6 +184,30 @@ def _get_url_and_payload( return url, payload +def _raise_sgs_response_error(res: httpx.Response, code: int) -> None: + if res.status_code == 429: + raise_for_status(res, context=f"SGS time series code={code}") + + try: + res_json = json.loads(res.text) + except json.JSONDecodeError: + res_json = {} + + if "error" in res_json: + raise SGSError(f"BCB error: {res_json['error']}") + if "erro" in res_json: + raise SGSError(f"BCB error: {res_json['erro']['detail']}") + + raise_for_status( + res, + context=f"SGS time series code={code}", + error_cls=SGSError, + not_found_cls=SGSError, + server_error_cls=SGSError, + error_message=f"Download error: code = {code}", + ) + + def _format_df(df: pd.DataFrame, code: SGSCode, freq: Optional[str]) -> pd.DataFrame: cns = {"data": "Date", "valor": code.name, "datafim": "enddate"} df = df.rename(columns=cns) @@ -336,27 +366,16 @@ def get_json( """ url, payload = _get_url_and_payload(code, start, end, last) logger.debug(f"Fetching SGS time series code={code} from {url.split('/dados')[0]}") - res = _CLIENT.get(url, params=payload) - logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") - - # Check for rate limiting first - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, + try: + res = _CLIENT.get(url, params=payload) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"SGS time series code={code}", error_cls=SGSError ) + logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") if res.status_code != 200: - try: - res_json = json.loads(res.text) - except json.JSONDecodeError: - res_json = {} - - if "error" in res_json: - raise SGSError(f"BCB error: {res_json['error']}") - elif "erro" in res_json: - raise SGSError(f"BCB error: {res_json['erro']['detail']}") - raise SGSError(f"Download error: code = {code}") + _raise_sgs_response_error(res, code) return str(res.text) @@ -396,29 +415,18 @@ async def async_get_json( logger.debug( f"Fetching SGS time series (async) code={code} from {url.split('/dados')[0]}" ) - res = await _ASYNC_CLIENT.get(url, params=payload) + try: + res = await _ASYNC_CLIENT.get(url, params=payload) + except httpx.HTTPError as ex: + raise_for_request_error( + ex, context=f"SGS time series code={code}", error_cls=SGSError + ) logger.debug( f"SGS (async) response: status={res.status_code}, length={len(res.text)}" ) - # Check for rate limiting first - if res.status_code == 429: - raise BCBRateLimitError( - "BCB API rate limit exceeded. Please try again later.", - status_code=429, - ) - if res.status_code != 200: - try: - res_json = json.loads(res.text) - except json.JSONDecodeError: - res_json = {} - - if "error" in res_json: - raise SGSError(f"BCB error: {res_json['error']}") - elif "erro" in res_json: - raise SGSError(f"BCB error: {res_json['erro']['detail']}") - raise SGSError(f"Download error: code = {code}") + _raise_sgs_response_error(res, code) return str(res.text) diff --git a/tests/test_async.py b/tests/test_async.py index dd069d0..0deefd5 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -10,6 +10,7 @@ from bcb import currency, sgs from bcb.odata.api import Expectativas +from bcb.exceptions import BCBRateLimitError, ODataError from tests.conftest import ( CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV, @@ -92,6 +93,16 @@ async def test_async_get_text_output(httpx_mock): assert "data" in result +async def test_async_get_json_rate_limit_raises(httpx_mock): + httpx_mock.add_response( + url=SGS_CODE_URL, + status_code=429, + ) + + with pytest.raises(BCBRateLimitError): + await sgs.async_get_json(1) + + # --------------------------------------------------------------------------- # Currency async tests # --------------------------------------------------------------------------- @@ -172,6 +183,30 @@ async def test_odata_query_async_text(httpx_mock): assert "value" in result +async def test_odata_query_async_status_error_raises(httpx_mock): + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/", + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/$metadata", + content=ODATA_METADATA_XML, + status_code=200, + ) + httpx_mock.add_response( + url=re.compile(r".*ExpectativasMercadoAnuais.*"), + text="server error", + status_code=500, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="OData query"): + await ep.query().limit(1).async_text() + + async def test_odata_query_async_collect(httpx_mock): """Test ODataQuery.async_collect() returns DataFrame.""" httpx_mock.add_response( diff --git a/tests/test_currency_negative.py b/tests/test_currency_negative.py index 8572362..544393d 100644 --- a/tests/test_currency_negative.py +++ b/tests/test_currency_negative.py @@ -6,6 +6,7 @@ import re from datetime import datetime +import httpx import pytest from bcb import currency @@ -56,6 +57,38 @@ def test_get_currency_id_list_500_raises(httpx_mock): currency._currency_id_list() +def test_get_currency_id_list_connection_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=PTAX_ID_LIST_URL, + ) + + with pytest.raises(BCBAPIError, match="Currency ID list"): + currency._currency_id_list() + + +def test_fetch_symbol_timeout_error_raises(httpx_mock): + from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_exception( + httpx.TimeoutException("request timed out"), + url=PTAX_RATE_URL, + ) + + with pytest.raises(BCBAPIError, match="Currency data for USD"): + currency._fetch_symbol_response("USD", START, END) + + def test_fetch_symbol_404_raises(httpx_mock): """Test that 404 when fetching rates raises BCBAPINotFoundError.""" from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..7ccfda9 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,84 @@ +"""Shared HTTP error handling tests.""" + +import httpx +import pytest + +from bcb.exceptions import ( + BCBAPIError, + BCBAPINotFoundError, + BCBAPIServerError, + BCBRateLimitError, + ODataError, + SGSError, +) +from bcb.http import raise_for_request_error, raise_for_status + + +def make_response(status_code: int) -> httpx.Response: + request = httpx.Request("GET", "https://example.test/resource") + return httpx.Response(status_code, request=request) + + +def test_raise_for_status_allows_expected_status() -> None: + raise_for_status(make_response(200), context="Example") + + +@pytest.mark.parametrize( + ("status_code", "error_cls"), + [ + (429, BCBRateLimitError), + (404, BCBAPINotFoundError), + (500, BCBAPIServerError), + ], +) +def test_raise_for_status_maps_common_http_statuses( + status_code: int, error_cls: type[BCBAPIError] +) -> None: + with pytest.raises(error_cls) as exc_info: + raise_for_status(make_response(status_code), context="Example") + + assert exc_info.value.status_code == status_code + + +def test_raise_for_status_maps_generic_client_error() -> None: + with pytest.raises(BCBAPIError) as exc_info: + raise_for_status(make_response(400), context="Example") + + assert exc_info.value.status_code == 400 + assert "Example" in str(exc_info.value) + + +def test_raise_for_status_supports_endpoint_specific_exceptions() -> None: + with pytest.raises(SGSError, match="SGS unavailable"): + raise_for_status( + make_response(503), + context="SGS", + server_error_cls=SGSError, + server_error_message="SGS unavailable", + ) + + +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("connection failed"), + httpx.TimeoutException("request timed out"), + ], +) +def test_raise_for_request_error_maps_transport_failures( + error: httpx.HTTPError, +) -> None: + with pytest.raises(BCBAPIError) as exc_info: + raise_for_request_error(error, context="Example") + + assert exc_info.value.status_code == 0 + assert "Example request failed" in str(exc_info.value) + + +def test_raise_for_request_error_supports_endpoint_specific_exceptions() -> None: + with pytest.raises(ODataError, match="OData request failed"): + raise_for_request_error( + httpx.ConnectError("offline"), + context="OData", + error_cls=ODataError, + ) diff --git a/tests/test_odata.py b/tests/test_odata.py index e626360..135ebdd 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -1,6 +1,7 @@ import re from datetime import datetime +import httpx import pandas as pd import pytest @@ -64,6 +65,58 @@ def test_invalid_endpoint_raises(httpx_mock): api.get_endpoint("DoesNotExist") +def test_service_root_status_error_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text="service unavailable", + status_code=503, + ) + + with pytest.raises(ODataError, match="OData service"): + Expectativas() + + +def test_service_root_connection_error_raises_odata_error(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=EXPECTATIVAS_BASE_URL, + ) + + with pytest.raises(ODataError, match="OData service"): + Expectativas() + + +def test_metadata_status_error_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_METADATA_URL, + text="metadata unavailable", + status_code=404, + ) + + with pytest.raises(ODataError, match="OData metadata"): + Expectativas() + + +def test_query_status_error_raises_odata_error(httpx_mock): + add_service_mocks(httpx_mock) + httpx_mock.add_response( + url=ENTITY_URL_PATTERN, + text="too many requests", + status_code=429, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="rate limit"): + ep.query().limit(1).collect() + + # --------------------------------------------------------------------------- # ODataProperty operator overloading # --------------------------------------------------------------------------- diff --git a/tests/test_sgs_negative.py b/tests/test_sgs_negative.py index 7e76dab..d2f272e 100644 --- a/tests/test_sgs_negative.py +++ b/tests/test_sgs_negative.py @@ -6,6 +6,7 @@ import json import re +import httpx import pytest from bcb import sgs @@ -51,6 +52,26 @@ def test_get_json_500_raises(httpx_mock): sgs.get_json(1) +def test_get_json_connection_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=SGS_CODE_URL, + ) + + with pytest.raises(SGSError, match="SGS time series"): + sgs.get_json(1) + + +def test_get_json_timeout_error_raises(httpx_mock): + httpx_mock.add_exception( + httpx.TimeoutException("request timed out"), + url=SGS_CODE_URL, + ) + + with pytest.raises(SGSError, match="SGS time series"): + sgs.get_json(1) + + # --------------------------------------------------------------------------- # Malformed data (JSON) # --------------------------------------------------------------------------- From 83c1518d3b321c8c3465f519994d93e4ed246878 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 15:52:53 -0300 Subject: [PATCH 04/14] Use public HTTP client accessors (#47) --- bcb/currency.py | 16 ++++++++-------- bcb/odata/framework.py | 17 +++++++++++------ bcb/sgs/__init__.py | 8 ++++---- tests/test_http.py | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/bcb/currency.py b/bcb/currency.py index eeac772..b9bb3fd 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -15,8 +15,8 @@ from lxml import html from bcb.http import ( - _CLIENT, - _ASYNC_CLIENT, + get_async_client, + get_client, raise_for_request_error, raise_for_status, ) @@ -164,7 +164,7 @@ def _currency_id_list( ) logger.debug(f"Fetching currency ID list from {url1}") try: - res = _CLIENT.get(url1) + res = get_client().get(url1) except httpx.HTTPError as ex: raise_for_request_error(ex, context="Currency ID list") logger.debug( @@ -225,7 +225,7 @@ def _get_valid_currency_list( url2 = f"https://www4.bcb.gov.br/Download/fechamento/M{_date:%Y%m%d}.csv" logger.debug(f"Fetching currency list from {url2}") try: - res = _CLIENT.get(url2) + res = get_client().get(url2) except httpx.HTTPError as ex: # Connection error: retry same date up to 3 times if n >= 3: @@ -336,7 +336,7 @@ def _fetch_symbol_response( url = _currency_url(cid, start_date, end_date) logger.debug(f"Fetching currency data for {symbol} from {url.split('?')[0]}") try: - res = _CLIENT.get(url) + res = get_client().get(url) except httpx.HTTPError as ex: raise_for_request_error(ex, context=f"Currency data for {symbol}") logger.debug( @@ -665,7 +665,7 @@ async def _async_currency_id_list( "method=exibeFormularioConsultaBoletim" ) try: - res = await _ASYNC_CLIENT.get(url1) + res = await get_async_client().get(url1) except httpx.HTTPError as ex: raise_for_request_error(ex, context="Currency ID list") raise_for_status( @@ -696,7 +696,7 @@ async def _async_get_valid_currency_list( url2 = f"https://www4.bcb.gov.br/Download/fechamento/M{_date:%Y%m%d}.csv" try: - res = await _ASYNC_CLIENT.get(url2) + res = await get_async_client().get(url2) except httpx.HTTPError as ex: if n >= 3: raise_for_request_error(ex, context="Currency list") @@ -759,7 +759,7 @@ async def _async_fetch_symbol_response( cid = await _async_get_currency_id(symbol) url = _currency_url(cid, start_date, end_date) try: - res = await _ASYNC_CLIENT.get(url) + res = await get_async_client().get(url) except httpx.HTTPError as ex: raise_for_request_error(ex, context=f"Currency data for {symbol}") diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index 728e83b..0f7112a 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -11,7 +11,12 @@ from lxml import etree from typing_extensions import Self -from bcb.http import _ASYNC_CLIENT, _CLIENT, raise_for_request_error, raise_for_status +from bcb.http import ( + get_async_client, + get_client, + raise_for_request_error, + raise_for_status, +) from bcb.exceptions import ODataError logger = logging.getLogger(__name__) @@ -285,7 +290,7 @@ def __init__(self, url: str) -> None: def _load_document(self) -> None: logger.debug(f"Fetching OData metadata from {self.url}") try: - res = _CLIENT.get(self.url) + res = get_client().get(self.url) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"OData metadata {self.url}", error_cls=ODataError @@ -393,7 +398,7 @@ class ODataService: def __init__(self, url: str) -> None: self.url = url try: - res = _CLIENT.get(self.url) + res = get_client().get(self.url) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"OData service {self.url}", error_cls=ODataError @@ -558,7 +563,7 @@ def collect(self) -> Any: return json.loads(self.text()) async def async_text(self) -> str: - """Async version of text(). Fetches OData response using _ASYNC_CLIENT.""" + """Async version of text(). Fetches OData response using shared client.""" params = self._build_parameters() if self.is_function and len(self.function_parameters): for p in self.entity.function.parameters: # type: ignore[union-attr] @@ -570,7 +575,7 @@ async def async_text(self) -> str: headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} url = self.odata_url() try: - res = await _ASYNC_CLIENT.get(url + "?" + qs, headers=headers) + res = await get_async_client().get(url + "?" + qs, headers=headers) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"OData query {url}", error_cls=ODataError @@ -602,7 +607,7 @@ def text(self) -> str: url = self.odata_url() logger.debug(f"Fetching OData query from {url}") try: - res = _CLIENT.get(url + "?" + qs, headers=headers) + res = get_client().get(url + "?" + qs, headers=headers) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"OData query {url}", error_cls=ODataError diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index a60e1dc..0591f53 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -22,8 +22,8 @@ import pandas as pd from bcb.http import ( - _CLIENT, - _ASYNC_CLIENT, + get_async_client, + get_client, raise_for_request_error, raise_for_status, ) @@ -367,7 +367,7 @@ def get_json( url, payload = _get_url_and_payload(code, start, end, last) logger.debug(f"Fetching SGS time series code={code} from {url.split('/dados')[0]}") try: - res = _CLIENT.get(url, params=payload) + res = get_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"SGS time series code={code}", error_cls=SGSError @@ -416,7 +416,7 @@ async def async_get_json( f"Fetching SGS time series (async) code={code} from {url.split('/dados')[0]}" ) try: - res = await _ASYNC_CLIENT.get(url, params=payload) + res = await get_async_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( ex, context=f"SGS time series code={code}", error_cls=SGSError diff --git a/tests/test_http.py b/tests/test_http.py index 7ccfda9..c41b7ee 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,5 +1,8 @@ """Shared HTTP error handling tests.""" +import ast +from pathlib import Path + import httpx import pytest @@ -82,3 +85,21 @@ def test_raise_for_request_error_supports_endpoint_specific_exceptions() -> None context="OData", error_cls=ODataError, ) + + +def test_feature_modules_do_not_import_private_http_clients() -> None: + private_names = {"_CLIENT", "_ASYNC_CLIENT"} + violations: list[str] = [] + for module_path in Path("bcb").rglob("*.py"): + if module_path == Path("bcb/http.py"): + continue + tree = ast.parse(module_path.read_text(), filename=str(module_path)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "bcb.http": + imported = {alias.name for alias in node.names} + private_imports = sorted(imported & private_names) + if private_imports: + names = ", ".join(private_imports) + violations.append(f"{module_path}: {names}") + + assert violations == [] From 59e55012446c44f976a3b3e194c309e9e89fc424 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 16:02:47 -0300 Subject: [PATCH 05/14] Harden OData error handling (#48) --- bcb/odata/framework.py | 89 ++++++++++++++++++++++++++++++++++-------- tests/test_async.py | 24 ++++++++++++ tests/test_odata.py | 84 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 16 deletions(-) diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index 0f7112a..bd1f830 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -26,6 +26,31 @@ _METADATA_CACHE: dict[str, "ODataMetadata"] = {} _METADATA_CACHE_LOCK = threading.RLock() + +def _load_json_object(text: str, *, context: str) -> dict[str, Any]: + try: + data = json.loads(text) + except json.JSONDecodeError as ex: + raise ODataError(f"{context} returned invalid JSON: {ex}") from ex + if not isinstance(data, dict): + raise ODataError(f"{context} returned invalid JSON payload: expected object") + return data + + +def _required_field(data: dict[str, Any], field: str, *, context: str) -> Any: + try: + return data[field] + except KeyError as ex: + raise ODataError(f"{context} response missing required field {field!r}") from ex + + +def _load_xml_document(content: bytes, *, context: str) -> Any: + try: + return etree.parse(BytesIO(content)) + except etree.XMLSyntaxError as ex: + raise ODataError(f"{context} returned invalid XML: {ex}") from ex + + # Edm.Boolean # Edm.Byte # Edm.Date @@ -278,14 +303,24 @@ class ODataMetadata: def __init__(self, url: str) -> None: self.url = url self._load_document() - _xpath = "edmx:DataServices/edm:Schema" - schema = self.doc.xpath(_xpath, namespaces=self.namespaces)[0] - self.namespace: str = schema.attrib["Namespace"] - self._used_elements: list[str] = [] - self._parse_entities(schema) - self._parse_entity_sets(schema) - self._parse_functions(schema) - self._parse_function_imports(schema) + try: + _xpath = "edmx:DataServices/edm:Schema" + schemas = self.doc.xpath(_xpath, namespaces=self.namespaces) + if not schemas: + raise ODataError(f"OData metadata {self.url} missing schema") + schema = schemas[0] + self.namespace = schema.attrib["Namespace"] + self._used_elements: list[str] = [] + self._parse_entities(schema) + self._parse_entity_sets(schema) + self._parse_functions(schema) + self._parse_function_imports(schema) + except ODataError: + raise + except (KeyError, IndexError, TypeError) as ex: + raise ODataError( + f"OData metadata {self.url} has invalid structure: {ex}" + ) from ex def _load_document(self) -> None: logger.debug(f"Fetching OData metadata from {self.url}") @@ -306,7 +341,7 @@ def _load_document(self) -> None: rate_limit_cls=ODataError, server_error_cls=ODataError, ) - self.doc = etree.parse(BytesIO(res.content)) + self.doc = _load_xml_document(res.content, context=f"OData metadata {self.url}") def _parse_entity(self, entity_element: Any, namespace: str) -> ODataEntity: name = entity_element.attrib["Name"] @@ -411,11 +446,27 @@ def __init__(self, url: str) -> None: rate_limit_cls=ODataError, server_error_cls=ODataError, ) - self.api_data: dict[str, Any] = json.loads(res.text) - self.endpoints: list[ODataEndPoint] = [ - ODataEndPoint(**x) for x in self.api_data["value"] - ] - self._odata_context_url: str = self.api_data["@odata.context"] + context = f"OData service {self.url}" + self.api_data = _load_json_object(res.text, context=context) + value = _required_field(self.api_data, "value", context=context) + if not isinstance(value, list): + raise ODataError("OData service response field 'value' must be a list") + endpoints = [] + for endpoint in value: + if not isinstance(endpoint, dict): + raise ODataError( + "OData service response field 'value' must contain objects" + ) + endpoints.append(ODataEndPoint(**endpoint)) + self.endpoints = endpoints + odata_context = _required_field( + self.api_data, "@odata.context", context=context + ) + if not isinstance(odata_context, str): + raise ODataError( + "OData service response field '@odata.context' must be a string" + ) + self._odata_context_url = odata_context # Use cached metadata if available, otherwise create and cache new one with _METADATA_CACHE_LOCK: @@ -560,7 +611,10 @@ def reset(self) -> None: self._params = {} def collect(self) -> Any: - return json.loads(self.text()) + url = self.odata_url() + data = _load_json_object(self.text(), context=f"OData query {url}") + _required_field(data, "value", context=f"OData query {url}") + return data async def async_text(self) -> str: """Async version of text(). Fetches OData response using shared client.""" @@ -592,7 +646,10 @@ async def async_text(self) -> str: async def async_collect(self) -> Any: """Async version of collect(). Awaits async_text() and parses JSON.""" - return json.loads(await self.async_text()) + url = self.odata_url() + data = _load_json_object(await self.async_text(), context=f"OData query {url}") + _required_field(data, "value", context=f"OData query {url}") + return data def text(self) -> str: params = self._build_parameters() diff --git a/tests/test_async.py b/tests/test_async.py index 0deefd5..d3a150e 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -207,6 +207,30 @@ async def test_odata_query_async_status_error_raises(httpx_mock): await ep.query().limit(1).async_text() +async def test_odata_query_async_malformed_json_raises(httpx_mock): + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/", + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/$metadata", + content=ODATA_METADATA_XML, + status_code=200, + ) + httpx_mock.add_response( + url=re.compile(r".*ExpectativasMercadoAnuais.*"), + text="not json", + status_code=200, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="OData query.*invalid JSON"): + await ep.query().limit(1).async_collect() + + async def test_odata_query_async_collect(httpx_mock): """Test ODataQuery.async_collect() returns DataFrame.""" httpx_mock.add_response( diff --git a/tests/test_odata.py b/tests/test_odata.py index 135ebdd..af4cd4a 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -86,6 +86,28 @@ def test_service_root_connection_error_raises_odata_error(httpx_mock): Expectativas() +def test_service_root_malformed_json_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text="not json", + status_code=200, + ) + + with pytest.raises(ODataError, match="OData service.*invalid JSON"): + Expectativas() + + +def test_service_root_missing_required_fields_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text="{}", + status_code=200, + ) + + with pytest.raises(ODataError, match="missing required field 'value'"): + Expectativas() + + def test_metadata_status_error_raises_odata_error(httpx_mock): httpx_mock.add_response( url=EXPECTATIVAS_BASE_URL, @@ -102,6 +124,38 @@ def test_metadata_status_error_raises_odata_error(httpx_mock): Expectativas() +def test_metadata_malformed_xml_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_METADATA_URL, + text=" Date: Sun, 14 Jun 2026 16:12:16 -0300 Subject: [PATCH 06/14] Document autonomous hardening loop --- docs/project-health-hardening.md | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/project-health-hardening.md b/docs/project-health-hardening.md index ddefd1e..2f58d54 100644 --- a/docs/project-health-hardening.md +++ b/docs/project-health-hardening.md @@ -58,6 +58,46 @@ For each implementation issue: 6. Push the branch and open a PR against `project-health-hardening`. 7. After merge, check off the item in issue #44. +## Autonomous Execution Loop + +Use this loop when the goal is to keep executing issue #44 until all work is +done or a human decision is required: + +```text +Autonomous #44 execution loop: + +Repeat until #44 has no unchecked work items: + +1. Read issue #44. +2. Pick the first unchecked work item. +3. Read that implementation issue. +4. Work from an up-to-date `project-health-hardening`. +5. Create a branch named `hardening/-`. +6. Keep the implementation scoped to that issue. +7. Run the Definition of Done from #44. +8. Open a PR targeting `project-health-hardening`. +9. Wait for GitHub checks. +10. If checks pass and the PR is mergeable: + - merge the PR + - update #44 + - close the implementation issue + - delete/prune the PR branch + - return to step 1 +11. Stop and ask for human input if a human decision is needed. + +Human-in-the-loop blockers include: +- unclear product/API behavior +- failing tests that require changing public behavior +- merge conflicts that are not mechanical +- CI failures that are not reproducible locally or not clearly transient +- dependency, release, or security decisions +- a task whose scope overlaps another unchecked issue +- any destructive git action beyond deleting the completed PR branch + +Do not skip unchecked issues. Do not merge a PR with failing required checks +unless the failure is confirmed unrelated/transient and documented. +``` + ## Default Checks Run the targeted tests for the issue, then use the standard project checks: @@ -67,6 +107,7 @@ uv run pytest -m "not integration" uv run ruff check bcb/ tests/ uv run ruff format --check bcb/ tests/ uv run mypy bcb/ +uv run --group docs sphinx-build -b html docs docs/_build/html ``` For integration-test issues, also run: From 3165c34677c0019d459635a4c17d982a62dfd8a7 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 17:29:47 -0300 Subject: [PATCH 07/14] Harden OData filter literal serialization (#49) --- bcb/odata/framework.py | 59 +++++++++++++++++++++++++++++++++++------- tests/test_odata.py | 37 ++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index bd1f830..9538a98 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -2,6 +2,7 @@ import json import logging +import math import threading from io import BytesIO from typing import Any, Optional, Union @@ -51,6 +52,52 @@ def _load_xml_document(content: bytes, *, context: str) -> Any: raise ODataError(f"{context} returned invalid XML: {ex}") from ex +def _format_odata_string_literal(value: Any) -> str: + if value is None: + raise ODataError("Edm.String filter values cannot be None") + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def _format_odata_literal(edm_type: Optional[str], value: Any) -> str: + if value is None: + raise ODataError(f"{edm_type or 'Unknown'} filter values cannot be None") + + if edm_type == "Edm.Decimal": + try: + decimal_value = float(value) + except (TypeError, ValueError) as ex: + raise ODataError(f"Invalid Edm.Decimal filter value: {value!r}") from ex + if not math.isfinite(decimal_value): + raise ODataError(f"Invalid Edm.Decimal filter value: {value!r}") + return f"{decimal_value}" + + if edm_type in ("Edm.Int16", "Edm.Int32", "Edm.Int64"): + try: + return f"{int(value)}" + except (TypeError, ValueError) as ex: + raise ODataError(f"Invalid {edm_type} filter value: {value!r}") from ex + + if edm_type == "Edm.String": + return _format_odata_string_literal(value) + + if edm_type == "Edm.Date": + try: + formatted = value.strftime("%Y-%m-%d") + except AttributeError as ex: + raise ODataError(f"Invalid Edm.Date filter value: {value!r}") from ex + if not isinstance(formatted, str): + raise ODataError(f"Invalid Edm.Date filter value: {value!r}") + return formatted + + if edm_type == "Edm.Boolean": + if not isinstance(value, bool): + raise ODataError(f"Invalid Edm.Boolean filter value: {value!r}") + return str(value).lower() + + raise ODataError(f"Unsupported OData filter literal type: {edm_type or 'Unknown'}") + + # Edm.Boolean # Edm.Byte # Edm.Date @@ -212,16 +259,8 @@ def __init__(self, obj: "ODataProperty", oth: Any, operator: str) -> None: self.operator = operator def statement(self) -> str: - if self.obj.type == "Edm.Decimal": - return f"{self.obj.name} {self.operator} {float(self.other)}" - elif self.obj.type == "Edm.Int32": - return f"{self.obj.name} {self.operator} {int(self.other)}" - elif self.obj.type == "Edm.String": - return f"{self.obj.name} {self.operator} '{str(self.other)}'" - elif self.obj.type == "Edm.Date": - return f"{self.obj.name} {self.operator} {self.other.strftime('%Y-%m-%d')}" - else: - return f"{self.obj.name} {self.operator} '{self.other}'" + literal = _format_odata_literal(self.obj.type, self.other) + return f"{self.obj.name} {self.operator} {literal}" def __str__(self) -> str: return self.statement() diff --git a/tests/test_odata.py b/tests/test_odata.py index af4cd4a..502b5e6 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -1,12 +1,12 @@ import re -from datetime import datetime +from datetime import date, datetime import httpx import pandas as pd import pytest from bcb.odata.api import Expectativas -from bcb.odata.framework import ODataPropertyFilter, ODataPropertyOrderBy +from bcb.odata.framework import ODataProperty, ODataPropertyFilter, ODataPropertyOrderBy from bcb.exceptions import ODataError from tests.conftest import ( ODATA_SERVICE_ROOT_JSON, @@ -215,6 +215,11 @@ def test_string_property_equality_filter(httpx_mock): assert str(f) == "Indicador eq 'IPCA'" +def test_string_property_filter_escapes_apostrophes(): + indicador = ODataProperty(Name="Indicador", Type="Edm.String") + assert str(indicador == "Focus's IPCA") == "Indicador eq 'Focus''s IPCA'" + + def test_decimal_property_comparison_filters(httpx_mock): add_service_mocks(httpx_mock) api = Expectativas() @@ -226,6 +231,34 @@ def test_decimal_property_comparison_filters(httpx_mock): assert str(mediana <= 4.0) == "Mediana le 4.0" +def test_date_property_filter_formats_dates(): + data = ODataProperty(Name="Data", Type="Edm.Date") + assert str(data == date(2024, 1, 31)) == "Data eq 2024-01-31" + + +def test_int_property_filter_formats_ints(): + prazo = ODataProperty(Name="Prazo", Type="Edm.Int32") + assert str(prazo == "12") == "Prazo eq 12" + + +@pytest.mark.parametrize( + ("prop", "value", "message"), + [ + (ODataProperty(Name="Indicador", Type="Edm.String"), None, "Edm.String"), + ( + ODataProperty(Name="Mediana", Type="Edm.Decimal"), + "not-a-number", + "Edm.Decimal", + ), + (ODataProperty(Name="Data", Type="Edm.Date"), "2024-01-31", "Edm.Date"), + (ODataProperty(Name="Codigo", Type="Edm.Guid"), "abc", "Unsupported"), + ], +) +def test_property_filter_invalid_values_raise_odata_error(prop, value, message): + with pytest.raises(ODataError, match=message): + str(ODataPropertyFilter(prop, value, "eq")) + + def test_property_orderby(httpx_mock): add_service_mocks(httpx_mock) api = Expectativas() From 993bc6289f3f56f059e2c712282a1918527c69d7 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 17:35:25 -0300 Subject: [PATCH 08/14] Align async currency missing-symbol behavior (#50) --- bcb/currency.py | 42 ++++++++++----- tests/test_async.py | 115 +++++++++++++++++++++++++++++------------ tests/test_currency.py | 27 ++++++++-- 3 files changed, 135 insertions(+), 49 deletions(-) diff --git a/bcb/currency.py b/bcb/currency.py index b9bb3fd..781626b 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -6,7 +6,7 @@ import threading from datetime import date, timedelta from io import BytesIO, StringIO -from typing import Dict, List, Literal, NamedTuple, Union, overload +from typing import Dict, List, Literal, NamedTuple, NoReturn, Union, overload from urllib.parse import urlencode import httpx @@ -304,6 +304,11 @@ def _get_currency_id(symbol: str) -> int: return int(matches.max()) +def _raise_no_valid_currency_symbols(symbols: List[str]) -> NoReturn: + requested = ", ".join(symbols) if symbols else "" + raise CurrencyNotFoundError(f"No valid currency symbols found: {requested}") + + def _fetch_symbol_response( symbol: str, start_date: DateInput, end_date: DateInput ) -> "httpx.Response": @@ -620,7 +625,7 @@ def get( except CurrencyNotFoundError: pass # Skip missing currencies if not results: - raise CurrencyNotFoundError(f"Currency not found: {symbols}") + _raise_no_valid_currency_symbols(symbols) if len(symbols) == 1: return results[symbols[0]] return results @@ -647,7 +652,7 @@ def get( else: raise ValueError("Unknown side value, use: bid, ask, both") else: - raise CurrencyNotFoundError(f"Currency not found: {symbols}") + _raise_no_valid_currency_symbols(symbols) async def _async_currency_id_list( @@ -847,24 +852,35 @@ async def async_get( if output == "text": results: Dict[str, str] = {} texts = await asyncio.gather( - *[_async_get_symbol_text(symbol, start, end) for symbol in symbols] + *[_async_get_symbol_text(symbol, start, end) for symbol in symbols], + return_exceptions=True, ) for symbol, text in zip(symbols, texts): - if text is not None: - results[symbol] = text + if isinstance(text, CurrencyNotFoundError): + continue + if isinstance(text, BaseException): + raise text + results[symbol] = text if not results: - raise CurrencyNotFoundError(f"Currency not found: {symbols}") + _raise_no_valid_currency_symbols(symbols) if len(symbols) == 1: return results[symbols[0]] return results dss = await asyncio.gather( - *[_async_get_symbol(symbol, start, end) for symbol in symbols] + *[_async_get_symbol(symbol, start, end) for symbol in symbols], + return_exceptions=True, ) - dss = [df for df in dss if df is not None] - - if len(dss) > 0: - df = pd.concat(dss, axis=1) + valid_dss = [] + for df in dss: + if isinstance(df, CurrencyNotFoundError): + continue + if isinstance(df, BaseException): + raise df + valid_dss.append(df) + + if len(valid_dss) > 0: + df = pd.concat(valid_dss, axis=1) if side in ("bid", "ask"): dx = df.reorder_levels([1, 0], axis=1).sort_index(axis=1) return dx[side] @@ -878,4 +894,4 @@ async def async_get( else: raise ValueError("Unknown side value, use: bid, ask, both") else: - raise CurrencyNotFoundError(f"Currency not found: {symbols}") + _raise_no_valid_currency_symbols(symbols) diff --git a/tests/test_async.py b/tests/test_async.py index d3a150e..30bbee5 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -10,7 +10,7 @@ from bcb import currency, sgs from bcb.odata.api import Expectativas -from bcb.exceptions import BCBRateLimitError, ODataError +from bcb.exceptions import BCBRateLimitError, CurrencyNotFoundError, ODataError from tests.conftest import ( CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV, @@ -32,6 +32,31 @@ SGS_CODE_URL = re.compile(r".*bcdata\.sgs\..*") +def add_currency_base_mocks(httpx_mock): + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + is_reusable=True, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + is_reusable=True, + ) + + +def add_currency_rate_mock(httpx_mock): + httpx_mock.add_response( + url=PTAX_RATE_URL, + text=CURRENCY_RATE_CSV, + status_code=200, + headers={"Content-Type": "text/csv"}, + is_reusable=True, + ) + + # --------------------------------------------------------------------------- # SGS async tests # --------------------------------------------------------------------------- @@ -110,22 +135,8 @@ async def test_async_get_json_rate_limit_raises(httpx_mock): async def test_async_get_symbol_returns_dataframe(httpx_mock): """Test async_get_symbol() returns DataFrame.""" - httpx_mock.add_response( - url=PTAX_ID_LIST_URL, - content=CURRENCY_ID_LIST_HTML, - status_code=200, - ) - httpx_mock.add_response( - url=PTAX_CSV_DOWNLOAD_URL, - text=CURRENCY_LIST_CSV, - status_code=200, - ) - httpx_mock.add_response( - url=PTAX_RATE_URL, - text=CURRENCY_RATE_CSV, - status_code=200, - headers={"Content-Type": "text/csv"}, - ) + add_currency_base_mocks(httpx_mock) + add_currency_rate_mock(httpx_mock) df = await currency._async_get_symbol("USD", START, END) assert df is not None assert ("USD", "bid") in df.columns @@ -134,26 +145,64 @@ async def test_async_get_symbol_returns_dataframe(httpx_mock): async def test_async_get_single_symbol_returns_dataframe(httpx_mock): """Test async_get() with single symbol returns DataFrame.""" - httpx_mock.add_response( - url=PTAX_ID_LIST_URL, - content=CURRENCY_ID_LIST_HTML, - status_code=200, - ) - httpx_mock.add_response( - url=PTAX_CSV_DOWNLOAD_URL, - text=CURRENCY_LIST_CSV, - status_code=200, - ) - httpx_mock.add_response( - url=PTAX_RATE_URL, - text=CURRENCY_RATE_CSV, - status_code=200, - headers={"Content-Type": "text/csv"}, - ) + add_currency_base_mocks(httpx_mock) + add_currency_rate_mock(httpx_mock) df = await currency.async_get("USD", START, END) assert df is not None +async def test_async_get_mixed_valid_invalid_symbols_returns_valid_dataframe( + httpx_mock, +): + add_currency_base_mocks(httpx_mock) + add_currency_rate_mock(httpx_mock) + + df = await currency.async_get(["USD", "ZAR"], START, END, side="both") + + assert "USD" in df.columns.get_level_values(0) + assert "ZAR" not in df.columns.get_level_values(0) + + +async def test_async_get_duplicate_symbols_returns_duplicate_columns(httpx_mock): + add_currency_base_mocks(httpx_mock) + add_currency_rate_mock(httpx_mock) + + df = await currency.async_get(["USD", "USD"], START, END, side="both") + + assert list(df.columns.get_level_values(0)).count("USD") == 4 + + +async def test_async_get_mixed_valid_invalid_text_returns_valid_dict(httpx_mock): + add_currency_base_mocks(httpx_mock) + add_currency_rate_mock(httpx_mock) + + result = await currency.async_get(["USD", "ZAR"], START, END, output="text") + + assert isinstance(result, dict) + assert list(result) == ["USD"] + assert "01122020" in result["USD"] + + +async def test_async_get_all_invalid_symbols_raise_clear_error(httpx_mock): + add_currency_base_mocks(httpx_mock) + + with pytest.raises( + CurrencyNotFoundError, + match="No valid currency symbols found: ZAR, ZZ1", + ): + await currency.async_get(["ZAR", "ZZ1"], START, END) + + +async def test_async_get_all_invalid_text_symbols_raise_clear_error(httpx_mock): + add_currency_base_mocks(httpx_mock) + + with pytest.raises( + CurrencyNotFoundError, + match="No valid currency symbols found: ZAR, ZZ1", + ): + await currency.async_get(["ZAR", "ZZ1"], START, END, output="text") + + # --------------------------------------------------------------------------- # OData async tests # --------------------------------------------------------------------------- diff --git a/tests/test_currency.py b/tests/test_currency.py index 6926c46..30eeae2 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -194,14 +194,20 @@ def test_currency_get_invalid_side(httpx_mock): def test_currency_get_unknown_symbol_raises(httpx_mock): add_id_list_mock(httpx_mock) add_currency_list_mock(httpx_mock) - with pytest.raises(CurrencyNotFoundError): + with pytest.raises( + CurrencyNotFoundError, + match="No valid currency symbols found: ZAR", + ): currency.get("ZAR", START, END) def test_currency_get_list_all_unknown_raises(httpx_mock): add_id_list_mock(httpx_mock) add_currency_list_mock(httpx_mock) - with pytest.raises(CurrencyNotFoundError): + with pytest.raises( + CurrencyNotFoundError, + match="No valid currency symbols found: ZAR, ZZ1", + ): currency.get(["ZAR", "ZZ1"], START, END) @@ -247,10 +253,25 @@ def test_currency_get_output_text_unknown_raises(httpx_mock): """get('ZAR', output='text') raises CurrencyNotFoundError.""" add_id_list_mock(httpx_mock) add_currency_list_mock(httpx_mock) - with pytest.raises(CurrencyNotFoundError): + with pytest.raises( + CurrencyNotFoundError, + match="No valid currency symbols found: ZAR", + ): currency.get("ZAR", START, END, output="text") +def test_currency_get_output_text_mixed_valid_invalid_returns_valid_dict(httpx_mock): + add_id_list_mock(httpx_mock) + add_currency_list_mock(httpx_mock) + add_rate_mock(httpx_mock) + + result = currency.get(["USD", "ZAR"], START, END, output="text") + + assert isinstance(result, dict) + assert list(result) == ["USD"] + assert "01122020" in result["USD"] + + def test_currency_get_output_dataframe_is_default(httpx_mock): """Default output still returns DataFrame.""" add_id_list_mock(httpx_mock) From b12a0183c40fbf7f9dead2c02f1f4e24bfb93f5d Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 17:41:02 -0300 Subject: [PATCH 09/14] Fix async HTTP client shutdown guidance (#51) --- bcb/http.py | 37 ++++++++++++++++---------- docs/async.rst | 16 +++++++---- tests/test_http.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/bcb/http.py b/bcb/http.py index 07b3a15..0641113 100644 --- a/bcb/http.py +++ b/bcb/http.py @@ -27,11 +27,16 @@ follow_redirects=True, ) -# Shared asynchronous HTTP client (for future async API) -_ASYNC_CLIENT = httpx.AsyncClient( - timeout=DEFAULT_TIMEOUT, - follow_redirects=True, -) + +def _make_async_client() -> httpx.AsyncClient: + return httpx.AsyncClient( + timeout=DEFAULT_TIMEOUT, + follow_redirects=True, + ) + + +# Shared asynchronous HTTP client +_ASYNC_CLIENT = _make_async_client() # Retry decorator for transient failures @@ -62,9 +67,18 @@ def get_async_client() -> httpx.AsyncClient: httpx.AsyncClient Shared async client with connection pooling and configured timeout. """ + global _ASYNC_CLIENT + if _ASYNC_CLIENT.is_closed: + _ASYNC_CLIENT = _make_async_client() return _ASYNC_CLIENT +async def aclose_async_client() -> None: + """Close the shared async client from async code.""" + if not _ASYNC_CLIENT.is_closed: + await _ASYNC_CLIENT.aclose() + + def close_async_client() -> None: """Close the shared async client. @@ -74,16 +88,11 @@ def close_async_client() -> None: import asyncio try: - loop = asyncio.get_event_loop() - if loop.is_running(): - # If called from async context, schedule closing - asyncio.create_task(_ASYNC_CLIENT.aclose()) - else: - # If called from sync context, run the close - loop.run_until_complete(_ASYNC_CLIENT.aclose()) + loop = asyncio.get_running_loop() except RuntimeError: - # No event loop, create one - asyncio.run(_ASYNC_CLIENT.aclose()) + asyncio.run(aclose_async_client()) + else: + loop.create_task(aclose_async_client()) T = TypeVar("T") diff --git a/docs/async.rst b/docs/async.rst index 29ff76e..ffc352e 100644 --- a/docs/async.rst +++ b/docs/async.rst @@ -202,7 +202,9 @@ Performance: Síncrono vs Assíncrono Limpeza de Recursos ------------------- -Para aplicações de longa duração, feche o cliente assíncrono quando terminar: +Para aplicações de longa duração, feche o cliente assíncrono quando terminar. +Em código assíncrono, use ``await http.aclose_async_client()`` dentro da +função principal: .. code-block:: python @@ -210,13 +212,17 @@ Para aplicações de longa duração, feche o cliente assíncrono quando termina from bcb import http async def main(): - # ... suas operações assíncronas ... - pass + try: + # ... suas operações assíncronas ... + pass + finally: + await http.aclose_async_client() asyncio.run(main()) - # Fechar cliente assíncrono - asyncio.run(http.close_async_client()) +Em código síncrono, quando não há uma event loop em execução, também é possível +chamar ``http.close_async_client()`` diretamente após terminar as operações +assíncronas. Limitações ---------- diff --git a/tests/test_http.py b/tests/test_http.py index c41b7ee..d800594 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,5 +1,6 @@ """Shared HTTP error handling tests.""" +import asyncio import ast from pathlib import Path @@ -14,6 +15,7 @@ ODataError, SGSError, ) +from bcb import http as http_module from bcb.http import raise_for_request_error, raise_for_status @@ -103,3 +105,67 @@ def test_feature_modules_do_not_import_private_http_clients() -> None: violations.append(f"{module_path}: {names}") assert violations == [] + + +class FakeAsyncClient: + def __init__(self, *, is_closed: bool = False) -> None: + self.is_closed = is_closed + self.close_count = 0 + + async def aclose(self) -> None: + self.is_closed = True + self.close_count += 1 + + +def test_get_async_client_recreates_closed_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed_client = FakeAsyncClient(is_closed=True) + replacement_client = FakeAsyncClient() + monkeypatch.setattr(http_module, "_ASYNC_CLIENT", closed_client) + monkeypatch.setattr(http_module, "_make_async_client", lambda: replacement_client) + + assert http_module.get_async_client() is replacement_client + + +def test_close_async_client_runs_from_sync_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = FakeAsyncClient() + monkeypatch.setattr(http_module, "_ASYNC_CLIENT", client) + + http_module.close_async_client() + + assert client.is_closed + assert client.close_count == 1 + + +def test_documented_async_shutdown_example_runs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = FakeAsyncClient() + monkeypatch.setattr(http_module, "_ASYNC_CLIENT", client) + + async def main() -> None: + await http_module.aclose_async_client() + + asyncio.run(main()) + + assert client.is_closed + assert client.close_count == 1 + + +def test_close_async_client_schedules_from_running_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = FakeAsyncClient() + monkeypatch.setattr(http_module, "_ASYNC_CLIENT", client) + + async def main() -> None: + http_module.close_async_client() + await asyncio.sleep(0) + + asyncio.run(main()) + + assert client.is_closed + assert client.close_count == 1 From f46d2310d5bba753c1994c57c7e59291384e503d Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 17:46:42 -0300 Subject: [PATCH 10/14] Harden currency response parsing (#52) --- bcb/currency.py | 83 ++++++++++++++++-------- tests/test_async.py | 33 +++++++++- tests/test_currency_negative.py | 109 +++++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 28 deletions(-) diff --git a/bcb/currency.py b/bcb/currency.py index 781626b..18d6db6 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -12,7 +12,7 @@ import httpx import numpy as np import pandas as pd -from lxml import html +from lxml import etree, html from bcb.http import ( get_async_client, @@ -309,6 +309,48 @@ def _raise_no_valid_currency_symbols(symbols: List[str]) -> NoReturn: raise CurrencyNotFoundError(f"No valid currency symbols found: {requested}") +def _is_html_response(response: httpx.Response) -> bool: + content_type = response.headers.get("Content-Type", "").lower() + if content_type.startswith("text/html"): + return True + body = response.content.lstrip().lower() + return body.startswith((b" str: + message = re.sub(r"^\W+", "", message) + message = re.sub(r"\W+$", "", message) + message = re.sub(r"\s+", " ", message) + return message.strip() + + +def _extract_currency_html_error(content: bytes) -> str | None: + try: + doc = html.parse(BytesIO(content)).getroot() + except (etree.ParserError, ValueError): + return None + xpath = "//div[@class='msgErro']" + for element in doc.xpath(xpath): + message = _clean_currency_error_message(str(element.text_content())) + if message: + return message + return None + + +def _raise_currency_html_error(response: httpx.Response, symbol: str) -> NoReturn: + message = _extract_currency_html_error(response.content) + if message: + raise BCBAPIError( + f"BCB API returned error for {symbol}: {message}", + status_code=400, + ) + raise BCBAPIError( + f"BCB API returned an HTML response for {symbol} " + "without a recognized BCB error message", + status_code=400, + ) + + def _fetch_symbol_response( symbol: str, start_date: DateInput, end_date: DateInput ) -> "httpx.Response": @@ -348,16 +390,9 @@ def _fetch_symbol_response( f"Currency data response: status={res.status_code}, length={len(res.content)}" ) - # Handle HTML error response (e.g., no data for date range) - if res.headers.get("Content-Type", "").startswith("text/html"): - doc = html.parse(BytesIO(res.content)).getroot() - xpath = "//div[@class='msgErro']" - elm = doc.xpath(xpath)[0] - x = elm.text - x = re.sub(r"^\W+", "", x) - x = re.sub(r"\W+$", "", x) - msg = f"BCB API returned error: {x} - {symbol}" - raise BCBAPIError(msg, status_code=400) + # Handle HTML error responses (e.g., no data for date range). + if _is_html_response(res): + _raise_currency_html_error(res, symbol) raise_for_status( res, @@ -386,7 +421,12 @@ def _validate_currency_csv(csv_text: str) -> pd.DataFrame: BCBAPIError If CSV format is invalid (wrong column count) """ - df = pd.read_csv(StringIO(csv_text), delimiter=";", header=None, dtype=str) + if not csv_text.strip(): + raise BCBAPIError("Currency CSV response is empty", status_code=400) + try: + df = pd.read_csv(StringIO(csv_text), delimiter=";", header=None, dtype=str) + except (pd.errors.EmptyDataError, pd.errors.ParserError) as e: + raise BCBAPIError(f"Failed to parse currency CSV: {e}", status_code=400) from e # Validate column count if len(df.columns) != 8: @@ -420,10 +460,10 @@ def _parse_currency_dates(df: pd.DataFrame) -> pd.DataFrame: """ try: df["Date"] = pd.to_datetime(df["Date"], format="%d%m%Y") - except ValueError as e: + except (TypeError, ValueError) as e: raise BCBAPIError( f"Failed to parse currency date column: {str(e)}", status_code=400 - ) + ) from e return df @@ -448,10 +488,10 @@ def _parse_currency_types(df: pd.DataFrame) -> pd.DataFrame: try: df["bid"] = df["bid"].str.replace(",", ".").astype(np.float64) df["ask"] = df["ask"].str.replace(",", ".").astype(np.float64) - except (ValueError, TypeError) as e: + except (TypeError, ValueError) as e: raise BCBAPIError( f"Failed to parse currency numeric columns: {str(e)}", status_code=400 - ) + ) from e return df @@ -768,15 +808,8 @@ async def _async_fetch_symbol_response( except httpx.HTTPError as ex: raise_for_request_error(ex, context=f"Currency data for {symbol}") - if res.headers.get("Content-Type", "").startswith("text/html"): - doc = html.parse(BytesIO(res.content)).getroot() - xpath = "//div[@class='msgErro']" - elm = doc.xpath(xpath)[0] - x = elm.text - x = re.sub(r"^\W+", "", x) - x = re.sub(r"\W+$", "", x) - msg = f"BCB API returned error: {x} - {symbol}" - raise BCBAPIError(msg, status_code=400) + if _is_html_response(res): + _raise_currency_html_error(res, symbol) raise_for_status( res, diff --git a/tests/test_async.py b/tests/test_async.py index 30bbee5..7c496a6 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -10,7 +10,12 @@ from bcb import currency, sgs from bcb.odata.api import Expectativas -from bcb.exceptions import BCBRateLimitError, CurrencyNotFoundError, ODataError +from bcb.exceptions import ( + BCBAPIError, + BCBRateLimitError, + CurrencyNotFoundError, + ODataError, +) from tests.conftest import ( CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV, @@ -203,6 +208,32 @@ async def test_async_get_all_invalid_text_symbols_raise_clear_error(httpx_mock): await currency.async_get(["ZAR", "ZZ1"], START, END, output="text") +async def test_async_get_symbol_unexpected_html_raises_bcb_error(httpx_mock): + add_currency_base_mocks(httpx_mock) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text="

temporary failure

", + status_code=200, + headers={}, + ) + + with pytest.raises(BCBAPIError, match="HTML response.*USD.*recognized"): + await currency._async_get_symbol("USD", START, END) + + +async def test_async_get_symbol_empty_response_body_raises_bcb_error(httpx_mock): + add_currency_base_mocks(httpx_mock) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text="", + status_code=200, + headers={"Content-Type": "text/csv"}, + ) + + with pytest.raises(BCBAPIError, match="empty"): + await currency._async_get_symbol("USD", START, END) + + # --------------------------------------------------------------------------- # OData async tests # --------------------------------------------------------------------------- diff --git a/tests/test_currency_negative.py b/tests/test_currency_negative.py index 544393d..df112eb 100644 --- a/tests/test_currency_negative.py +++ b/tests/test_currency_negative.py @@ -143,6 +143,111 @@ def test_fetch_symbol_429_rate_limit_raises(httpx_mock): # --------------------------------------------------------------------------- +def test_fetch_symbol_html_error_extracts_message(httpx_mock): + """HTML error pages with the expected element raise BCBAPIError.""" + from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text="
No data available
", + status_code=200, + headers={"Content-Type": "text/html"}, + ) + + with pytest.raises(BCBAPIError, match="No data available"): + currency._fetch_symbol_response("USD", START, END) + + +def test_fetch_symbol_unexpected_html_without_content_type_raises(httpx_mock): + """HTML bodies without Content-Type or msgErro still raise BCBAPIError.""" + from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text="

temporary failure

", + status_code=200, + headers={}, + ) + + with pytest.raises(BCBAPIError, match="HTML response.*USD.*recognized"): + currency._fetch_symbol_response("USD", START, END) + + +def test_get_symbol_missing_content_type_valid_csv_parses(httpx_mock): + """Missing Content-Type alone does not reject a valid CSV response.""" + from tests.conftest import ( + CURRENCY_ID_LIST_HTML, + CURRENCY_LIST_CSV, + CURRENCY_RATE_CSV, + ) + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text=CURRENCY_RATE_CSV, + status_code=200, + headers={}, + ) + + df = currency._get_symbol("USD", START, END) + + assert ("USD", "bid") in df.columns + + +def test_get_symbol_empty_response_body_raises(httpx_mock): + """Empty CSV responses raise BCBAPIError.""" + from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV + + httpx_mock.add_response( + url=PTAX_ID_LIST_URL, + content=CURRENCY_ID_LIST_HTML, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_CSV_DOWNLOAD_URL, + text=CURRENCY_LIST_CSV, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_RATE_URL, + text="", + status_code=200, + headers={"Content-Type": "text/csv"}, + ) + + with pytest.raises(BCBAPIError, match="empty"): + currency._get_symbol("USD", START, END) + + def test_get_symbol_malformed_csv_wrong_column_count_raises(httpx_mock): """Test that CSV with wrong column count raises BCBAPIError.""" from tests.conftest import CURRENCY_ID_LIST_HTML, CURRENCY_LIST_CSV @@ -189,7 +294,7 @@ def test_get_symbol_malformed_csv_invalid_date_format_raises(httpx_mock): text=malformed_csv, status_code=200, ) - with pytest.raises(BCBAPIError): + with pytest.raises(BCBAPIError, match="date column"): currency._get_symbol("USD", START, END) @@ -214,7 +319,7 @@ def test_get_symbol_malformed_csv_invalid_numeric_conversion_raises(httpx_mock): text=malformed_csv, status_code=200, ) - with pytest.raises(BCBAPIError): + with pytest.raises(BCBAPIError, match="numeric columns"): currency._get_symbol("USD", START, END) From 7c908f1d1ff1e2118cc531eb66a9fade832eb365 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 17:57:48 -0300 Subject: [PATCH 11/14] Validate public API inputs early (#53) --- bcb/currency.py | 83 +++++++++++++++++------- bcb/sgs/__init__.py | 60 +++++++++++++---- bcb/sgs/regional_economy.py | 100 ++++++++++++++++++----------- tests/sgs/test_regional_economy.py | 15 +++++ tests/sgs/test_series.py | 5 +- tests/test_async.py | 20 ++++++ tests/test_currency.py | 23 +++++-- tests/test_currency_negative.py | 8 ++- tests/test_sgs_negative.py | 27 +++++++- 9 files changed, 255 insertions(+), 86 deletions(-) diff --git a/bcb/currency.py b/bcb/currency.py index 18d6db6..05d25a7 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -563,6 +563,39 @@ def _get_symbol_text(symbol: str, start_date: DateInput, end_date: DateInput) -> # Type alias for text output with multiple symbols CurrencyTextResult = Dict[str, str] # Maps symbol → CSV text +CurrencySide = Literal["ask", "bid", "both"] +CurrencyGroupBy = Literal["symbol", "side"] +CurrencyOutput = Literal["dataframe", "text"] + + +def _normalize_currency_symbols(symbols: Union[str, List[str]]) -> List[str]: + if isinstance(symbols, str): + symbols = [symbols] + if not symbols: + raise ValueError("At least one currency symbol must be provided") + for symbol in symbols: + if not isinstance(symbol, str) or not symbol.strip(): + raise ValueError(f"Currency symbols must be non-empty strings: {symbol!r}") + return symbols + + +def _validate_currency_query_inputs( + symbols: Union[str, List[str]], + start: DateInput, + end: DateInput, + side: str, + groupby: str, + output: str, +) -> List[str]: + if output not in ("dataframe", "text"): + raise ValueError("Unknown output value, use: dataframe, text") + if side not in ("bid", "ask", "both"): + raise ValueError("Unknown side value, use: bid, ask, both") + if groupby not in ("symbol", "side"): + raise ValueError("Unknown groupby value, use: symbol, side") + Date(start) + Date(end) + return _normalize_currency_symbols(symbols) @overload @@ -570,8 +603,8 @@ def get( symbols: str, start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["dataframe"] = ..., ) -> pd.DataFrame: ... @@ -581,8 +614,8 @@ def get( symbols: List[str], start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["dataframe"] = ..., ) -> pd.DataFrame: ... @@ -592,8 +625,8 @@ def get( symbols: str, start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["text"] = ..., ) -> str: ... @@ -603,8 +636,8 @@ def get( symbols: List[str], start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["text"] = ..., ) -> CurrencyTextResult: ... @@ -613,9 +646,9 @@ def get( symbols: Union[str, List[str]], start: DateInput, end: DateInput, - side: str = "ask", - groupby: str = "symbol", - output: str = "dataframe", + side: CurrencySide = "ask", + groupby: CurrencyGroupBy = "symbol", + output: CurrencyOutput = "dataframe", ) -> Union[pd.DataFrame, str, Dict[str, str]]: """ Retorna um DataFrame pandas com séries temporais com taxas de câmbio. @@ -633,12 +666,14 @@ def get( end : string, int, date, datetime, Timestamp Data de início da série. Interpreta diferentes tipos e formatos de datas. - side : str + side : {"ask", "bid", "both"}, default "ask" Define se a série retornada vem com os ``ask`` prices, ``bid`` prices ou ``both`` para ambos. - groupby : str + groupby : {"symbol", "side"}, default "symbol" Define se os índices de coluna são agrupados por ``symbol`` ou por ``side``. + output : {"dataframe", "text"}, default "dataframe" + Define o formato de saída. Use ``"text"`` para retornar o CSV bruto. Returns ------- @@ -653,8 +688,9 @@ def get( DataFrame : Série temporal com cotações diárias das moedas solicitadas. """ - if isinstance(symbols, str): - symbols = [symbols] + symbols = _validate_currency_query_inputs( + symbols, start, end, side, groupby, output + ) if output == "text": results: Dict[str, str] = {} @@ -848,9 +884,9 @@ async def async_get( symbols: Union[str, List[str]], start: DateInput, end: DateInput, - side: str = "ask", - groupby: str = "symbol", - output: str = "dataframe", + side: CurrencySide = "ask", + groupby: CurrencyGroupBy = "symbol", + output: CurrencyOutput = "dataframe", ) -> Union[pd.DataFrame, str, Dict[str, str]]: """ Retorna um DataFrame pandas com séries temporais com taxas de câmbio (async version). @@ -867,11 +903,11 @@ async def async_get( Data de início da série end : string, int, date, datetime, Timestamp Data final da série - side : str + side : {"ask", "bid", "both"} ``'ask'``, ``'bid'`` ou ``'both'`` - groupby : str + groupby : {"symbol", "side"} ``'symbol'`` ou ``'side'`` - output : str + output : {"dataframe", "text"} ``'dataframe'`` ou ``'text'`` Returns @@ -879,8 +915,9 @@ async def async_get( Union[pd.DataFrame, str, Dict[str, str]] Série temporal conforme especificado """ - if isinstance(symbols, str): - symbols = [symbols] + symbols = _validate_currency_query_inputs( + symbols, start, end, side, groupby, output + ) if output == "text": results: Dict[str, str] = {} diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 0591f53..6643960 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -105,6 +105,16 @@ def __repr__(self) -> str: ] +def _validate_sgs_output(output: str) -> None: + if output not in ("dataframe", "text"): + raise ValueError("Unknown output value, use: dataframe, text") + + +def _validate_last(last: int) -> None: + if not isinstance(last, int) or last < 0: + raise ValueError(f"last must be a non-negative integer, got {last!r}") + + def _validate_sgs_code(code: SGSCode) -> None: """Validate SGSCode value. @@ -145,22 +155,32 @@ def _codes(codes: SGSCodeInput) -> Generator[SGSCode, None, None]: _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, tuple): + if len(codes) != 2: + raise ValueError("Named SGS code tuples must contain (name, code)") code_obj = SGSCode.from_named(codes[1], codes[0]) _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, list): + if not codes: + raise ValueError("At least one SGS code must be provided") for cd in codes: if isinstance(cd, tuple): + if len(cd) != 2: + raise ValueError("Named SGS code tuples must contain (name, code)") code_obj = SGSCode.from_named(cd[1], cd[0]) else: code_obj = SGSCode.from_code(cd) _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, Mapping): + if not codes: + raise ValueError("At least one SGS code must be provided") for name, code in codes.items(): code_obj = SGSCode.from_named(code, name) _validate_sgs_code(code_obj) yield code_obj + else: + raise ValueError(f"Unsupported SGS code input: {codes!r}") def _get_url_and_payload( @@ -169,6 +189,7 @@ def _get_url_and_payload( end_date: Optional[DateInput], last: int, ) -> Tuple[str, Dict[str, str]]: + _validate_last(last) payload: Dict[str, str] = {"formato": "json"} if last == 0: if start_date is not None or end_date is not None: @@ -252,7 +273,7 @@ def get( last: int = 0, multi: bool = True, freq: Optional[str] = None, - output: str = "dataframe", + output: Literal["dataframe", "text"] = "dataframe", ) -> Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]]: """ Retorna um DataFrame pandas com séries temporais obtidas do SGS. @@ -309,9 +330,12 @@ def get( Mapeamento de código → JSON bruto (quando ``output='text'`` e múltiplos códigos). """ + _validate_sgs_output(output) + code_list = list(_codes(codes)) + if output == "text": results: Dict[int, str] = {} - for code in _codes(codes): + for code in code_list: results[code.value] = get_json(code.value, start, end, last) values = list(results.values()) if len(values) == 1: @@ -319,7 +343,7 @@ def get( return results dfs = [] - for code in _codes(codes): + for code in code_list: text = get_json(code.value, start, end, last) df = pd.read_json(StringIO(text)) df = _format_df(df, code, freq) @@ -334,7 +358,7 @@ def get( def get_json( - code: int, + code: int | str, start: Optional[DateInput] = None, end: Optional[DateInput] = None, last: int = 0, @@ -364,23 +388,27 @@ def get_json( JSON : série temporal univariada em formato JSON. """ - url, payload = _get_url_and_payload(code, start, end, last) - logger.debug(f"Fetching SGS time series code={code} from {url.split('/dados')[0]}") + code_obj = SGSCode.from_code(code) + _validate_sgs_code(code_obj) + url, payload = _get_url_and_payload(code_obj.value, start, end, last) + logger.debug( + f"Fetching SGS time series code={code_obj.value} from {url.split('/dados')[0]}" + ) try: res = get_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( - ex, context=f"SGS time series code={code}", error_cls=SGSError + ex, context=f"SGS time series code={code_obj.value}", error_cls=SGSError ) logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") if res.status_code != 200: - _raise_sgs_response_error(res, code) + _raise_sgs_response_error(res, code_obj.value) return str(res.text) async def async_get_json( - code: int, + code: int | str, start: Optional[DateInput] = None, end: Optional[DateInput] = None, last: int = 0, @@ -411,22 +439,25 @@ async def async_get_json( SGSError Se a API retorna um erro """ - url, payload = _get_url_and_payload(code, start, end, last) + code_obj = SGSCode.from_code(code) + _validate_sgs_code(code_obj) + url, payload = _get_url_and_payload(code_obj.value, start, end, last) logger.debug( - f"Fetching SGS time series (async) code={code} from {url.split('/dados')[0]}" + f"Fetching SGS time series (async) code={code_obj.value} " + f"from {url.split('/dados')[0]}" ) try: res = await get_async_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( - ex, context=f"SGS time series code={code}", error_cls=SGSError + ex, context=f"SGS time series code={code_obj.value}", error_cls=SGSError ) logger.debug( f"SGS (async) response: status={res.status_code}, length={len(res.text)}" ) if res.status_code != 200: - _raise_sgs_response_error(res, code) + _raise_sgs_response_error(res, code_obj.value) return str(res.text) @@ -437,7 +468,7 @@ async def async_get( last: int = 0, multi: bool = True, freq: Optional[str] = None, - output: str = "dataframe", + output: Literal["dataframe", "text"] = "dataframe", ) -> Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]]: """ Retorna um DataFrame pandas com séries temporais obtidas do SGS (async version). @@ -467,6 +498,7 @@ async def async_get( Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]] Série(s) temporal(is) conforme especificado """ + _validate_sgs_output(output) code_list = list(_codes(codes)) # Concurrent HTTP requests via asyncio.gather() diff --git a/bcb/sgs/regional_economy.py b/bcb/sgs/regional_economy.py index 2413975..e52e814 100644 --- a/bcb/sgs/regional_economy.py +++ b/bcb/sgs/regional_economy.py @@ -126,46 +126,69 @@ } +def _normalize_mode(mode: str) -> str: + if not isinstance(mode, str): + raise ValueError("mode must be one of: PF, PJ, total") + normalized = mode.upper() + if normalized == "ALL": + normalized = "TOTAL" + if normalized not in ("PF", "PJ", "TOTAL"): + raise ValueError("Unknown mode value, use: PF, PJ, total") + return normalized + + +def _normalize_locations(states_or_region: Union[str, List[str]]) -> List[str]: + locations = ( + [states_or_region] if isinstance(states_or_region, str) else states_or_region + ) + if not isinstance(locations, list) or not locations: + raise ValueError("At least one state or region must be provided") + + normalized = [] + for location in locations: + if not isinstance(location, str) or not location.strip(): + raise ValueError(f"Not a valid state or region: {location!r}") + normalized.append(location.upper()) + return normalized + + def get_non_performing_loans_codes( states_or_region: Union[str, List[str]], mode: str = "total" ) -> Dict[str, str]: - is_state = False - is_region = False - states_or_region = ( - [states_or_region] if isinstance(states_or_region, str) else states_or_region - ) - states_or_region = [location.upper() for location in states_or_region] - if any( - location in list(NON_PERFORMING_LOANS_BY_STATE_TOTAL.keys()) - for location in states_or_region - ): - is_state = True - elif any( - location in list(NON_PERFORMING_LOANS_BY_REGION_TOTAL.keys()) - for location in states_or_region - ): - is_region = True - - if not is_state and not is_region: - raise Exception(f"Not a valid state or region: {states_or_region}") - - codes = {} - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_TOTAL - if is_state: - if mode.upper() == "PF": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_PF - elif mode.upper() == "PJ": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_PJ - elif is_region: - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_TOTAL - if mode.upper() == "PF": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_PF - elif mode.upper() == "PJ": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_PJ - - for location in states_or_region: - codes[location] = non_performing_loans_by_location[location] - return codes + locations = _normalize_locations(states_or_region) + normalized_mode = _normalize_mode(mode) + + states = set(NON_PERFORMING_LOANS_BY_STATE_TOTAL) + regions = set(NON_PERFORMING_LOANS_BY_REGION_TOTAL) + invalid_locations = [ + location + for location in locations + if location not in states and location not in regions + ] + if invalid_locations: + raise ValueError(f"Not a valid state or region: {invalid_locations}") + + # Some codes are ambiguous: "SE" is both Sergipe and Sudeste. + # Preserve the historical state-first behavior for all-state requests. + if all(location in states for location in locations): + mappings = { + "PF": NON_PERFORMING_LOANS_BY_STATE_PF, + "PJ": NON_PERFORMING_LOANS_BY_STATE_PJ, + "TOTAL": NON_PERFORMING_LOANS_BY_STATE_TOTAL, + } + elif all(location in regions for location in locations): + mappings = { + "PF": NON_PERFORMING_LOANS_BY_REGION_PF, + "PJ": NON_PERFORMING_LOANS_BY_REGION_PJ, + "TOTAL": NON_PERFORMING_LOANS_BY_REGION_TOTAL, + } + else: + raise ValueError("Cannot mix states and regions in the same request") + + non_performing_loans_by_location = mappings[normalized_mode] + return { + location: non_performing_loans_by_location[location] for location in locations + } def get_non_performing_loans( @@ -194,7 +217,8 @@ def get_non_performing_loans( states_or_region (List[str]): Uma lista com estado ou região. mode (str): O tipo de inadimplência. Pode ser "PF" (pessoas físicas), - "PJ" (pessoas jurídicas) ou "total" (inadimplência total). + "PJ" (pessoas jurídicas), "total" ou "all" + (inadimplência total). start : str, int, date, datetime, Timestamp Data de início da série. Interpreta diferentes tipos e formatos de datas. diff --git a/tests/sgs/test_regional_economy.py b/tests/sgs/test_regional_economy.py index acdd8b6..fe992d4 100644 --- a/tests/sgs/test_regional_economy.py +++ b/tests/sgs/test_regional_economy.py @@ -22,6 +22,21 @@ def test_get_non_performing_loans_codes_by_state_total( ): assert get_non_performing_loans_codes(states) == expected_codes + def test_get_non_performing_loans_codes_all_alias(self): + assert get_non_performing_loans_codes(["BA"], mode="all") == {"BA": "15929"} + + def test_get_non_performing_loans_codes_invalid_mode_raises(self): + with pytest.raises(ValueError, match="mode"): + get_non_performing_loans_codes(["BA"], mode="company") + + def test_get_non_performing_loans_codes_invalid_location_raises(self): + with pytest.raises(ValueError, match="Not a valid state or region"): + get_non_performing_loans_codes(["XX"]) + + def test_get_non_performing_loans_codes_mixed_state_region_raises(self): + with pytest.raises(ValueError, match="Cannot mix"): + get_non_performing_loans_codes(["BA", "N"]) + @pytest.mark.integration class TestGetNonPerformingLoans: diff --git a/tests/sgs/test_series.py b/tests/sgs/test_series.py index b02c0d3..b348a7f 100644 --- a/tests/sgs/test_series.py +++ b/tests/sgs/test_series.py @@ -88,9 +88,8 @@ def test_series_code_iter_dict(): def test_series_code_iter_unknown_type(): - # None falls through all isinstance checks → empty generator - x = list(sgs._codes(None)) # type: ignore[arg-type] - assert len(x) == 0 + with pytest.raises(ValueError, match="Unsupported SGS code input"): + list(sgs._codes(None)) # type: ignore[arg-type] # --------------------------------------------------------------------------- diff --git a/tests/test_async.py b/tests/test_async.py index 7c496a6..9618972 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -133,6 +133,16 @@ async def test_async_get_json_rate_limit_raises(httpx_mock): await sgs.async_get_json(1) +async def test_async_get_empty_sgs_code_list_raises(): + with pytest.raises(ValueError, match="At least one SGS code"): + await sgs.async_get([]) + + +async def test_async_get_invalid_sgs_output_raises(): + with pytest.raises(ValueError, match="output"): + await sgs.async_get(1, output="xml") # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # Currency async tests # --------------------------------------------------------------------------- @@ -156,6 +166,16 @@ async def test_async_get_single_symbol_returns_dataframe(httpx_mock): assert df is not None +async def test_async_get_invalid_currency_side_raises(): + with pytest.raises(ValueError, match="Unknown side"): + await currency.async_get("USD", START, END, side="mid") # type: ignore[arg-type] + + +async def test_async_get_invalid_currency_output_raises(): + with pytest.raises(ValueError, match="Unknown output"): + await currency.async_get("USD", START, END, output="json") # type: ignore[arg-type] + + async def test_async_get_mixed_valid_invalid_symbols_returns_valid_dataframe( httpx_mock, ): diff --git a/tests/test_currency.py b/tests/test_currency.py index 30eeae2..cc9557e 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -183,12 +183,25 @@ def test_currency_get_both_side_groupby(httpx_mock): assert ("ask", "USD") in df.columns -def test_currency_get_invalid_side(httpx_mock): - add_id_list_mock(httpx_mock) - add_currency_list_mock(httpx_mock) - add_rate_mock(httpx_mock) +def test_currency_get_invalid_side(): with pytest.raises(ValueError, match="Unknown side"): - currency.get("USD", START, END, side="mid") + currency.get("USD", START, END, side="mid") # type: ignore[arg-type] + + +def test_currency_get_invalid_groupby(): + with pytest.raises(ValueError, match="Unknown groupby"): + currency.get( + "USD", + START, + END, + side="both", + groupby="market", # type: ignore[arg-type] + ) + + +def test_currency_get_invalid_output(): + with pytest.raises(ValueError, match="Unknown output"): + currency.get("USD", START, END, output="json") # type: ignore[arg-type] def test_currency_get_unknown_symbol_raises(httpx_mock): diff --git a/tests/test_currency_negative.py b/tests/test_currency_negative.py index df112eb..692f5f6 100644 --- a/tests/test_currency_negative.py +++ b/tests/test_currency_negative.py @@ -330,10 +330,16 @@ def test_get_symbol_malformed_csv_invalid_numeric_conversion_raises(httpx_mock): def test_get_empty_symbol_list_raises(httpx_mock): """Test that empty symbol list raises an error.""" - with pytest.raises((ValueError, CurrencyNotFoundError)): + with pytest.raises(ValueError, match="At least one currency symbol"): currency.get([], START, END) +def test_get_blank_symbol_raises(): + """Blank currency symbols fail before HTTP requests.""" + with pytest.raises(ValueError, match="non-empty"): + currency.get("", START, END) + + def test_get_invalid_date_input_raises(): """Test that invalid date input is handled properly.""" # Invalid date format should raise ValueError (from Date class) diff --git a/tests/test_sgs_negative.py b/tests/test_sgs_negative.py index d2f272e..339a114 100644 --- a/tests/test_sgs_negative.py +++ b/tests/test_sgs_negative.py @@ -151,11 +151,34 @@ def test_sgs_code_string_non_numeric_raises(httpx_mock): def test_get_empty_code_list(): """Test that empty code list raises ValueError.""" - # Empty list results in no DataFrames to concat, which raises ValueError - with pytest.raises(ValueError, match="No objects to concatenate"): + with pytest.raises(ValueError, match="At least one SGS code"): sgs.get([]) +def test_get_empty_code_mapping(): + """Test that empty code mappings raise ValueError.""" + with pytest.raises(ValueError, match="At least one SGS code"): + sgs.get({}) + + +def test_get_invalid_output_raises(): + """Unsupported output values fail before HTTP requests.""" + with pytest.raises(ValueError, match="output"): + sgs.get(1, output="xml") # type: ignore[arg-type] + + +def test_get_negative_last_raises(): + """Negative last values fail before HTTP requests.""" + with pytest.raises(ValueError, match="last"): + sgs.get(1, last=-1) + + +def test_get_json_negative_code_raises(): + """get_json validates single public code inputs.""" + with pytest.raises(ValueError, match="positive"): + sgs.get_json(-1) + + # --------------------------------------------------------------------------- # Edge cases and boundary conditions # --------------------------------------------------------------------------- From e3c1df81ace7f714c833abddb3c10c53daf9f442 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 18:05:13 -0300 Subject: [PATCH 12/14] Strengthen OData and HTTP test coverage (#54) --- pyproject.toml | 2 +- tests/test_async.py | 24 ++++ tests/test_http.py | 27 +++++ tests/test_odata.py | 269 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 320 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c23169..16422d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ omit = ["tests/*"] [tool.coverage.report] show_missing = true -fail_under = 70 +fail_under = 75 [tool.mypy] python_version = "3.10" diff --git a/tests/test_async.py b/tests/test_async.py index 9618972..77be26b 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -6,6 +6,7 @@ import re from datetime import datetime +import httpx import pytest from bcb import currency, sgs @@ -307,6 +308,29 @@ async def test_odata_query_async_status_error_raises(httpx_mock): await ep.query().limit(1).async_text() +async def test_odata_query_async_transport_error_raises(httpx_mock): + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/", + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/$metadata", + content=ODATA_METADATA_XML, + status_code=200, + ) + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=re.compile(r".*ExpectativasMercadoAnuais.*"), + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="OData query.*network down"): + await ep.query().limit(1).async_text() + + async def test_odata_query_async_malformed_json_raises(httpx_mock): httpx_mock.add_response( url="https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/", diff --git a/tests/test_http.py b/tests/test_http.py index d800594..86731bf 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -28,6 +28,10 @@ def test_raise_for_status_allows_expected_status() -> None: raise_for_status(make_response(200), context="Example") +def test_raise_for_status_allows_expected_status_tuple() -> None: + raise_for_status(make_response(202), context="Example", expected_status=(200, 202)) + + @pytest.mark.parametrize( ("status_code", "error_cls"), [ @@ -63,6 +67,17 @@ def test_raise_for_status_supports_endpoint_specific_exceptions() -> None: ) +def test_raise_for_status_uses_custom_rate_limit_message() -> None: + with pytest.raises(BCBRateLimitError, match="Slow down") as exc_info: + raise_for_status( + make_response(429), + context="Example", + rate_limit_message="Slow down", + ) + + assert exc_info.value.status_code == 429 + + @pytest.mark.parametrize( "error", [ @@ -169,3 +184,15 @@ async def main() -> None: assert client.is_closed assert client.close_count == 1 + + +def test_with_retry_decorator_returns_successful_result() -> None: + calls: list[int] = [] + + @http_module.with_retry + def sample(value: int) -> int: + calls.append(value) + return value * 2 + + assert sample(21) == 42 + assert calls == [21] diff --git a/tests/test_odata.py b/tests/test_odata.py index 502b5e6..a4fd7e8 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -6,7 +6,14 @@ import pytest from bcb.odata.api import Expectativas -from bcb.odata.framework import ODataProperty, ODataPropertyFilter, ODataPropertyOrderBy +from bcb.odata.framework import ( + ODataParameter, + ODataProperty, + ODataPropertyFilter, + ODataPropertyOrderBy, + ODataService, + str_types, +) from bcb.exceptions import ODataError from tests.conftest import ( ODATA_SERVICE_ROOT_JSON, @@ -22,6 +29,41 @@ "https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/$metadata" ) ENTITY_URL_PATTERN = re.compile(r".*ExpectativasMercadoAnuais.*") +FUNCTION_METADATA_XML = b""" + + + + + + + + + + + + + + + + + + + + +""" + +FUNCTION_SERVICE_ROOT_JSON = """{ + "@odata.context": "https://example.test/odata/$metadata", + "value": [ + {"name": "CotacaoMoedaPeriodo", "kind": "FunctionImport", "url": "CotacaoMoedaPeriodo"} + ] +}""" + +FUNCTION_BASE_URL = "https://example.test/odata/" +FUNCTION_METADATA_URL = "https://example.test/odata/$metadata" +FUNCTION_URL_PATTERN = re.compile(r".*CotacaoMoedaPeriodo.*") def add_service_mocks(httpx_mock): @@ -38,6 +80,19 @@ def add_service_mocks(httpx_mock): ) +def add_function_service_mocks(httpx_mock): + httpx_mock.add_response( + url=FUNCTION_BASE_URL, + text=FUNCTION_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=FUNCTION_METADATA_URL, + content=FUNCTION_METADATA_XML, + status_code=200, + ) + + # --------------------------------------------------------------------------- # Service / metadata instantiation # --------------------------------------------------------------------------- @@ -108,6 +163,45 @@ def test_service_root_missing_required_fields_raises_odata_error(httpx_mock): Expectativas() +def test_service_root_value_must_be_list(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=( + '{"@odata.context": "' + + EXPECTATIVAS_METADATA_URL + + '", "value": {"name": "ExpectativasMercadoAnuais"}}' + ), + status_code=200, + ) + + with pytest.raises(ODataError, match="value.*list"): + Expectativas() + + +def test_service_root_value_items_must_be_objects(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text='{"@odata.context": "' + + EXPECTATIVAS_METADATA_URL + + '", "value": ["bad"]}', + status_code=200, + ) + + with pytest.raises(ODataError, match="value.*objects"): + Expectativas() + + +def test_service_root_context_must_be_string(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text='{"@odata.context": 123, "value": []}', + status_code=200, + ) + + with pytest.raises(ODataError, match="@odata.context.*string"): + Expectativas() + + def test_metadata_status_error_raises_odata_error(httpx_mock): httpx_mock.add_response( url=EXPECTATIVAS_BASE_URL, @@ -156,6 +250,50 @@ def test_metadata_missing_schema_raises_odata_error(httpx_mock): Expectativas() +def test_metadata_invalid_structure_raises_odata_error(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_METADATA_URL, + text=""" + + + + +""", + status_code=200, + ) + + with pytest.raises(ODataError, match="invalid structure"): + Expectativas() + + +def test_service_reuses_cached_metadata(httpx_mock): + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_METADATA_URL, + content=ODATA_METADATA_XML, + status_code=200, + ) + httpx_mock.add_response( + url=EXPECTATIVAS_BASE_URL, + text=ODATA_SERVICE_ROOT_JSON, + status_code=200, + ) + + first = Expectativas() + second = Expectativas() + + assert second.service.metadata is first.service.metadata + + def test_query_status_error_raises_odata_error(httpx_mock): add_service_mocks(httpx_mock) httpx_mock.add_response( @@ -201,6 +339,20 @@ def test_query_missing_value_raises_odata_error(httpx_mock): ep.query().limit(1).collect() +def test_query_transport_error_raises_odata_error(httpx_mock): + add_service_mocks(httpx_mock) + httpx_mock.add_exception( + httpx.ConnectError("network down"), + url=ENTITY_URL_PATTERN, + ) + + api = Expectativas() + ep = api.get_endpoint("ExpectativasMercadoAnuais") + + with pytest.raises(ODataError, match="OData query.*network down"): + ep.query().limit(1).text() + + # --------------------------------------------------------------------------- # ODataProperty operator overloading # --------------------------------------------------------------------------- @@ -241,6 +393,11 @@ def test_int_property_filter_formats_ints(): assert str(prazo == "12") == "Prazo eq 12" +def test_boolean_property_filter_formats_booleans(): + ativo = ODataProperty(Name="Ativo", Type="Edm.Boolean") + assert str(ativo == True) == "Ativo eq true" # noqa: E712 + + @pytest.mark.parametrize( ("prop", "value", "message"), [ @@ -268,6 +425,22 @@ def test_property_orderby(httpx_mock): assert str(ep.Mediana.desc()) == "Mediana desc" +def test_parameter_formatting_and_type_mapping(): + decimal_param = ODataParameter(Name="valor", Type="Edm.Decimal") + int_param = ODataParameter(Name="prazo", Type="Edm.Int32", Nullable="false") + string_param = ODataParameter(Name="moeda", Type="Edm.String", Nullable="true") + bool_param = ODataParameter(Name="ativo", Type="Edm.Boolean") + + assert decimal_param.format("1.25") == "1.25" + assert int_param.format("12") == "12" + assert string_param.format("USD") == "'USD'" + assert bool_param.format(True) == "'True'" + assert int_param.required + assert not string_param.required + assert str_types("Edm.TimeOfDay") == "datetime" + assert str_types("Edm.Guid") == "Edm.Guid" + + # --------------------------------------------------------------------------- # Query chain building # --------------------------------------------------------------------------- @@ -315,6 +488,100 @@ def test_endpoint_get_shortcut(httpx_mock): assert len(df) == 1 +def test_query_serializes_filter_orderby_select_and_pagination(httpx_mock): + add_service_mocks(httpx_mock) + httpx_mock.add_response( + url=ENTITY_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + api = Expectativas() + entity = api.service["ExpectativasMercadoAnuais"] + + query = ( + api.service.query(entity) + .filter(entity.Indicador == "Focus's IPCA", entity.Mediana > 4) + .orderby(entity.Data.desc()) + .select(entity.Indicador, entity.Mediana) + .limit(5) + .skip(10) + ) + data = query.collect() + + request = httpx_mock.get_requests()[-1] + assert data["value"][0]["Indicador"] == "IPCA" + assert request.url.params["$format"] == "json" + assert ( + request.url.params["$filter"] + == "Indicador eq 'Focus''s IPCA' and Mediana gt 4.0" + ) + assert request.url.params["$orderby"] == "Data desc" + assert request.url.params["$select"] == "Indicador,Mediana" + assert request.url.params["$top"] == "5" + assert request.url.params["$skip"] == "10" + + +def test_query_reset_clears_filters_ordering_and_pagination(httpx_mock): + add_service_mocks(httpx_mock) + api = Expectativas() + entity = api.service["ExpectativasMercadoAnuais"] + query = ( + api.service.query(entity) + .filter(entity.Indicador == "IPCA") + .orderby(entity.Data.asc()) + .limit(5) + ) + + query.reset() + + assert query._build_parameters() == {"$format": "json"} + + +def test_function_import_query_serialization(httpx_mock): + add_function_service_mocks(httpx_mock) + httpx_mock.add_response( + url=FUNCTION_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + service = ODataService(FUNCTION_BASE_URL) + function_import = service["CotacaoMoedaPeriodo"] + + result = ( + service.query(function_import) + .parameters(moeda="USD", dataInicial="01-01-2020", limite=5) + .limit(1) + .text() + ) + + request = httpx_mock.get_requests()[-1] + assert "IPCA" in result + assert ( + str(request.url).split("?", 1)[0] + == "https://example.test/odata/CotacaoMoedaPeriodo(moeda=@moeda,dataInicial=@dataInicial,limite=@limite)" + ) + assert request.url.params["@moeda"] == "'USD'" + assert request.url.params["@dataInicial"] == "'01-01-2020'" + assert request.url.params["@limite"] == "5" + assert request.url.params["$top"] == "1" + + +def test_function_import_missing_required_parameter_raises(httpx_mock): + add_function_service_mocks(httpx_mock) + service = ODataService(FUNCTION_BASE_URL) + + with pytest.raises(ODataError, match="Parameter not set: moeda"): + service.query(service["CotacaoMoedaPeriodo"]).text() + + +def test_function_import_unknown_parameter_raises(httpx_mock): + add_function_service_mocks(httpx_mock) + service = ODataService(FUNCTION_BASE_URL) + + with pytest.raises(ODataError, match="Unknown parameter"): + service.query(service["CotacaoMoedaPeriodo"]).parameters(unknown="x") + + # --------------------------------------------------------------------------- # DATE_COLUMNS — configurable date detection (Phase 7.1) # --------------------------------------------------------------------------- From 2a6cc9e6a47730a4d599a77d94259b19c492e795 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 18:12:26 -0300 Subject: [PATCH 13/14] Clean up documentation drift (#55) --- README.md | 30 +++++++++++++++++------- bcb/currency.py | 22 ++++++++++-------- bcb/sgs/__init__.py | 44 +++++++++++++++++++---------------- bcb/sgs/regional_economy.py | 12 +++++----- docs/async.rst | 18 ++++++++++---- docs/currency.rst | 8 +++---- docs/expectativas.rst | 4 +++- docs/odata.rst | 25 ++++++++++---------- docs/sgs.rst | 8 +++++-- docs/taxajuros.rst | 4 ++-- examples/async_usage.py | 16 +++++++++---- examples/currency_exchange.py | 7 +++--- examples/sgs_time_series.py | 5 ++-- 13 files changed, 120 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 50bcfb0..bd6cb93 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Implementado no módulo `currency`, realiza webscraping no site do [Conversor de ### OData - APIs Estruturadas O Banco Central disponibiliza diversas informações em APIs que seguem o padrão [OData](https://odata.org). Inclui: -- **PTAX**: Boletins diários de taxas de câmbio com dados institucionalmentedetalhados +- **PTAX**: Boletins diários de taxas de câmbio com dados institucionalmente detalhados - **Expectativas**: Expectativas de mercado coletadas do Boletim FOCUS - **TaxaJuros**: Diversas taxas de juros (Selic, CDI, Cheque especial, etc.) - **MercadoImobiliario**: Dados de financiamento imobiliário @@ -60,7 +60,7 @@ Use esta tabela para escolher o módulo certo para seu caso de uso: | Dados de financiamento imobiliário | `bcb.odata` (MercadoImobiliario) | Originações, taxas médias, volumes | | Informações de instituições financeiras | `bcb.odata` (IFDATA) | Dados de balanço, informações regulatórias | | Análise de dados avançada com filtros | `bcb.odata` (qualquer serviço) | API encadeável, filtragem tipo SQL, ordenação, seleção | -| Busca concorrente de dados | Qualquer módulo com `async_get()` | Requisições não-bloqueantes, melhor performance para operações em massa | +| Busca concorrente de dados | APIs assíncronas (`sgs`, `currency` e OData) | Requisições não-bloqueantes com `async_get()`, `Endpoint.async_get()` e `ODataQuery.async_collect()` | ## Início Rápido @@ -106,16 +106,20 @@ df = endpoint.query().filter(endpoint.Indicador == "IPCA").limit(100).collect() - Serviços OData: Varia; consulte documentação BCB para endpoints específicos ### P: Posso buscar dados de forma assíncrona? -**R:** Sim! Todos os módulos têm métodos `async_get()` ou similares. Use-os para requisições concorrentes: +**R:** Sim. SGS e currency oferecem `async_get()`, e os endpoints OData oferecem `async_get()` e `async_collect()`. Feche o cliente assíncrono ao final de aplicações de longa duração: ```python import asyncio -from bcb import sgs +from bcb import http, sgs async def main(): - results = await asyncio.gather( - sgs.async_get(1), # SELIC - sgs.async_get(433), # IPCA - ) + try: + results = await asyncio.gather( + sgs.async_get(1), # SELIC + sgs.async_get(433), # IPCA + ) + return results + finally: + await http.aclose_async_client() asyncio.run(main()) ``` @@ -162,7 +166,7 @@ logger.setLevel(logging.DEBUG) - Limites de requisições: APIs BCB podem ter limites; implemente backoff se necessário - Cache: Cache de moedas persiste em memória; limpe se atualizações de dados importarem - Pool de conexões: Usa httpx com connection pooling por padrão -- API Assíncrona: Use métodos async para comportamento verdadeiramente não-bloqueante +- API Assíncrona: use métodos async para comportamento verdadeiramente não-bloqueante e chame `await bcb.http.aclose_async_client()` no encerramento de aplicações assíncronas longas ### P: Como contribuo ou reporto problemas? **R:** Visite o [repositório GitHub](https://github.com/wilsonfreitas/python-bcb) para: @@ -171,6 +175,14 @@ logger.setLevel(logging.DEBUG) - Enviar pull requests - Ver documentação +### P: Como gero a documentação localmente? +**R:** As dependências de documentação ficam no grupo `docs` do `uv`: +```shell +uv run --group docs sphinx-build -b html docs docs/_build/html +``` + +A saída HTML é gerada em `docs/_build/html`. Edite os arquivos fonte em `docs/`; não edite os arquivos gerados em `docs/_build`. + ### P: Onde encontro documentação mais detalhada? **R:** - [Documentação de API](https://wilsonfreitas.github.io/python-bcb/) diff --git a/bcb/currency.py b/bcb/currency.py index 05d25a7..bf82cd7 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -660,12 +660,12 @@ def get( Códigos das moedas padrão ISO. O código de uma única moeda que retorna uma série temporal univariada e uma lista de códigos retorna uma série temporal multivariada. - start : str, int, date, datetime, Timestamp - Data de início da série. - Interpreta diferentes tipos e formatos de datas. - end : string, int, date, datetime, Timestamp - Data de início da série. - Interpreta diferentes tipos e formatos de datas. + start : str, date, datetime or bcb.utils.Date + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. side : {"ask", "bid", "both"}, default "ask" Define se a série retornada vem com os ``ask`` prices, ``bid`` prices ou ``both`` para ambos. @@ -899,10 +899,12 @@ async def async_get( ---------- symbols : str, List[str] Códigos das moedas padrão ISO - start : str, int, date, datetime, Timestamp - Data de início da série - end : string, int, date, datetime, Timestamp - Data final da série + start : str, date, datetime or bcb.utils.Date + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. side : {"ask", "bid", "both"} ``'ask'``, ``'bid'`` ou ``'both'`` groupby : {"symbol", "side"} diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 6643960..8934d14 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -291,12 +291,12 @@ def get( Com códigos numéricos é interessante utilizar os nomes com os códigos para definir os nomes nas colunas das séries temporais. - start : str, int, date, datetime, Timestamp - Data de início da série. - Interpreta diferentes tipos e formatos de datas. - end : string, int, date, datetime, Timestamp - Data final da série. - Interpreta diferentes tipos e formatos de datas. + start : str, date, datetime or bcb.utils.Date + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. last : int Retorna os últimos ``last`` elementos disponíveis da série temporal solicitada. Se ``last`` for maior que 0 (zero) os argumentos ``start`` @@ -371,12 +371,12 @@ def get_json( code : int Código da série temporal - start : str, int, date, datetime, Timestamp - Data de início da série. - Interpreta diferentes tipos e formatos de datas. - end : string, int, date, datetime, Timestamp - Data final da série. - Interpreta diferentes tipos e formatos de datas. + start : str, date, datetime or bcb.utils.Date + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. last : int Retorna os últimos ``last`` elementos disponíveis da série temporal solicitada. Se ``last`` for maior que 0 (zero) os argumentos ``start`` @@ -420,10 +420,12 @@ async def async_get_json( ---------- code : int Código da série temporal - start : str, int, date, datetime, Timestamp, optional - Data de início da série - end : string, int, date, datetime, Timestamp, optional - Data final da série + start : str, date, datetime or bcb.utils.Date, optional + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date, optional + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. last : int Retorna os últimos ``last`` elementos disponíveis @@ -480,10 +482,12 @@ async def async_get( ---------- codes : {int, List[int], List[str], Dict[str:int]} Código(s) da série temporal - start : str, int, date, datetime, Timestamp, optional - Data de início da série - end : string, int, date, datetime, Timestamp, optional - Data final da série + start : str, date, datetime or bcb.utils.Date, optional + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date, optional + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. last : int Retorna os últimos ``last`` elementos disponíveis multi : bool diff --git a/bcb/sgs/regional_economy.py b/bcb/sgs/regional_economy.py index e52e814..e2904b7 100644 --- a/bcb/sgs/regional_economy.py +++ b/bcb/sgs/regional_economy.py @@ -219,12 +219,12 @@ def get_non_performing_loans( mode (str): O tipo de inadimplência. Pode ser "PF" (pessoas físicas), "PJ" (pessoas jurídicas), "total" ou "all" (inadimplência total). - start : str, int, date, datetime, Timestamp - Data de início da série. - Interpreta diferentes tipos e formatos de datas. - end : string, int, date, datetime, Timestamp - Data final da série. - Interpreta diferentes tipos e formatos de datas. + start : str, date, datetime or bcb.utils.Date + Data de início da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. + end : str, date, datetime or bcb.utils.Date + Data final da série. Strings usam o formato ``YYYY-MM-DD``; + ``'today'`` e ``'now'`` também são aceitos. last : int Retorna os últimos ``last`` elementos disponíveis da série temporal solicitada. Se ``last`` for maior que 0 (zero) os argumentos ``start`` diff --git a/docs/async.rst b/docs/async.rst index ffc352e..f1e0c2a 100644 --- a/docs/async.rst +++ b/docs/async.rst @@ -77,12 +77,20 @@ Busca taxas de câmbio de forma assíncrona com a mesma interface que a versão .. code-block:: python import asyncio - from bcb import currency + from bcb import currency, http async def main(): - # Buscar taxas de câmbio - usd = await currency.async_get('USD', start='2024-01-01', end='2024-12-31') - print(usd.head()) + try: + # Buscar múltiplas taxas de câmbio em paralelo + rates = await currency.async_get( + ['USD', 'EUR'], + start='2024-01-01', + end='2024-12-31', + side='both', + ) + print(rates.head()) + finally: + await http.aclose_async_client() asyncio.run(main()) @@ -259,4 +267,4 @@ Veja Também * :ref:`SGS` — Documentação completa do módulo SGS * :ref:`Conversor de Moedas` — Documentação do módulo currency * :ref:`OData` — Documentação do cliente OData -* `asyncio — asyncpython `_ +* `asyncio — documentação Python `_ diff --git a/docs/currency.rst b/docs/currency.rst index c2fac88..eb75822 100644 --- a/docs/currency.rst +++ b/docs/currency.rst @@ -15,7 +15,7 @@ API OData de Moedas __ documentacao_ -A classe :py:class:`bcb.PTAX` retorna cotações de moedas os obtidas a partir da `API de Moedas`__ do BCB. +A classe :py:class:`bcb.PTAX` retorna cotações de moedas obtidas a partir da `API de Moedas`__ do BCB. Esta implementação é mais estável que a do :ref:`Conversor de Moedas`. .. ipython:: python @@ -47,7 +47,7 @@ são preenchidos com 0 para ter 2 dígitos. .. ipython:: python ptax.describe('CotacaoMoedaPeriodo') - + ep = ptax.get_endpoint('CotacaoMoedaPeriodo') (ep.query() .parameters(moeda='AUD', @@ -58,7 +58,7 @@ são preenchidos com 0 para ter 2 dígitos. Conversor de Moedas ------------------- -O módulo :py:mod:`bcb.currency` obtem dados de moedas do conversor de moedas do Banco Central através de webscraping. +O módulo :py:mod:`bcb.currency` obtém dados de moedas do conversor de moedas do Banco Central através de webscraping. Os parâmetros ``start`` e ``end`` aceitam strings ``YYYY-MM-DD``, ``datetime.date``, ``datetime.datetime`` ou :py:class:`bcb.utils.Date`. .. ipython:: python @@ -103,6 +103,6 @@ retornado um ``dict`` mapeando símbolo ISO → CSV string. f.write(raw) O CSV retornado usa ponto-e-vírgula como separador, datas no formato ``DDMMYYYY`` e vírgula -como separador decimal — exatamente como devolvido pela API PTAX do BCB. +como separador decimal — exatamente como devolvido pelo serviço de câmbio do BCB. O comportamento padrão (retorno de DataFrame) é mantido quando o parâmetro não é informado. diff --git a/docs/expectativas.rst b/docs/expectativas.rst index f7e266c..ab1aa34 100644 --- a/docs/expectativas.rst +++ b/docs/expectativas.rst @@ -91,9 +91,11 @@ ordenando colunas e selecionando as colunas na saída. .. ipython:: python + from datetime import date + (ep.query() .filter(ep.Indicador == 'IPCA', ep.DataReferencia == 2023) - .filter(ep.Data >= '2022-01-01') + .filter(ep.Data >= date(2022, 1, 1)) .filter(ep.tipoCalculo == 'C') .select(ep.Data, ep.Media, ep.Mediana) .orderby(ep.Data.desc()) diff --git a/docs/odata.rst b/docs/odata.rst index e5aa59f..aac1c15 100644 --- a/docs/odata.rst +++ b/docs/odata.rst @@ -118,18 +118,18 @@ Quero obter os 10 dias em 2023 que apresentam as maiores médias transacionadas Para executar essa query utilizo o método ``select`` passando as propriedades Data e Media, encadeio o método ``filter`` filtrando a propriedade Data maiores que 2023-01-01, e note -que aqui utilizo um objeto ``datetime``, pois na descrição do *endpoint* ``PixLiquidadosAtual`` -a propriedade Data é do tipo ``datetime``. +que aqui utilizo um objeto ``date``; objetos ``datetime`` também são aceitos. Na descrição do *endpoint* ``PixLiquidadosAtual``, +a propriedade Data aparece como ``datetime`` porque representa um campo OData ``Edm.Date``. Sigo com o método ``orderby`` passando a propriedade média e indicando que a ordenação é decrescente e concluo com o método ``limit`` para obter os 10 primeiros registros. Na última linha executo o método ``collect`` que executa a consulta e retorna um DataFrame com os resultados. .. ipython:: python - from datetime import datetime + from datetime import date (ep.query() .select(ep.Data, ep.Media) - .filter(ep.Data >= datetime(2023, 1, 1)) + .filter(ep.Data >= date(2023, 1, 1)) .orderby(ep.Media.desc()) .limit(5) .collect()) @@ -145,7 +145,7 @@ mas não a executa. (ep.query() .select(ep.Data, ep.Media) - .filter(ep.Data >= datetime(2023, 1, 1)) + .filter(ep.Data >= date(2023, 1, 1)) .orderby(ep.Media.desc()) .limit(5) .show()) @@ -189,7 +189,7 @@ Mais filtros podem ser adicionados ao método ``filter``, e também podemos anin query = (ep.query() .filter(ep.Indicador == 'IPCA', ep.DataReferencia == 2023) - .filter(ep.Data >= '2022-01-01') + .filter(ep.Data >= date(2022, 1, 1)) .filter(ep.tipoCalculo == 'C') .limit(5)) query.show() @@ -199,18 +199,17 @@ Todos os filtros estão no atributo ``$filter`` da consulta e são concatenados É necessário conhecer o tipo da propriedade para saber como passar o objeto para a consulta. Os tipos de propriedade podem ser: str, float, int e datetime. -Por exemplo, na API do PIX, a propriedade ``Data`` é do tipo ``datetime`` e por isso é necessário passar um -objeto ``datetime`` para o método ``filter``. +Para propriedades OData ``Edm.Date``, passe um objeto ``datetime.date`` ou ``datetime.datetime`` para o método ``filter``; strings de data não são convertidas automaticamente pelo construtor de filtros. .. ipython:: python ep = pix.get_endpoint("PixLiquidadosAtual") (ep.query() - .filter(ep.Data >= datetime(2023, 1, 1)) + .filter(ep.Data >= date(2023, 1, 1)) .limit(5) .show()) -O objeto ``datetime`` é formatado como data na consulta, note que não há aspas na definição da data no filtro. +O objeto ``date`` ou ``datetime`` é formatado como data na consulta; note que não há aspas na definição da data no filtro. Ordenando os Dados ^^^^^^^^^^^^^^^^^^ @@ -284,7 +283,7 @@ Esse método é importante para investigar as consultas na API de forma rápida. ep = pix.get_endpoint("PixLiquidadosAtual") (ep.query() - .filter(ep.Data >= datetime(2023, 1, 1)) + .filter(ep.Data >= date(2023, 1, 1)) .limit(5) .collect()) @@ -419,10 +418,10 @@ O comportamento padrão (retorno de DataFrame) é mantido quando o parâmetro n Classe ODataAPI --------------- -O portal de Dados Abertos to Banco Central apresenta diversas APIs OData, são +O portal de Dados Abertos do Banco Central apresenta diversas APIs OData, são dezenas de APIs disponíveis. A URL com metadados de cada API pode ser obtida no `portal `_. -A classe :py:class:`bcb.odata.api.ODataAPI` permite acessar qualquer API Odata de posse da sua URL. +A classe :py:class:`bcb.odata.api.ODataAPI` permite acessar qualquer API OData de posse da sua URL. Por exemplo, a API de estatísticas de operações registradas no Selic tem a seguinte URL:: diff --git a/docs/sgs.rst b/docs/sgs.rst index 55b0d93..5e31118 100644 --- a/docs/sgs.rst +++ b/docs/sgs.rst @@ -1,10 +1,12 @@ SGS === -A função :py:func:`bcb.sgs.get` obtem os dados do webservice do Banco Central , -interface json do serviço BCData/SGS - +A função :py:func:`bcb.sgs.get` obtém os dados do webservice do Banco Central, +interface JSON do serviço BCData/SGS - `Sistema Gerenciador de Séries Temporais (SGS) `_. +Os parâmetros ``start`` e ``end`` aceitam strings ``YYYY-MM-DD``, ``datetime.date``, ``datetime.datetime`` ou :py:class:`bcb.utils.Date`. Também é possível usar ``last`` para buscar os últimos ``n`` pontos disponíveis. + Exemplos -------- @@ -70,6 +72,8 @@ O comportamento padrão (retorno de DataFrame) é mantido quando o parâmetro n Dados de Inadimplência de Operações de Crédito ============================================== +Os modos aceitos são ``PF`` (pessoas físicas), ``PJ`` (pessoas jurídicas) e ``total``; ``all`` é aceito como alias de ``total``. Os locais devem ser todos estados ou todos regiões, sem misturar os dois tipos na mesma chamada. + .. ipython:: python from bcb.sgs.regional_economy import get_non_performing_loans diff --git a/docs/taxajuros.rst b/docs/taxajuros.rst index 3f6456a..e305ee1 100644 --- a/docs/taxajuros.rst +++ b/docs/taxajuros.rst @@ -4,14 +4,14 @@ Taxas de Juros A API de taxas de juros de operações de crédito pode ser acessada através da classe :py:class:`bcb.TaxaJuros`. -.. _documentacao: https://olinda.bcb.gov.br/olinda/servico/TaxaJuros/versao/v1/documentacao +.. _documentacao: https://olinda.bcb.gov.br/olinda/servico/taxaJuros/versao/v2/documentacao __ documentacao_ Os dados são obtidos a partir da `API de Taxas de Juros`__. -Esta API tem os ``EntitySets``: +Esta API usa o serviço ``taxaJuros`` versão ``v2`` e tem os ``EntitySets``: .. ipython:: python diff --git a/examples/async_usage.py b/examples/async_usage.py index fcb1a31..8d06506 100644 --- a/examples/async_usage.py +++ b/examples/async_usage.py @@ -6,7 +6,7 @@ """ import asyncio -from bcb import sgs, currency +from bcb import http, sgs, currency from bcb.odata.api import Expectativas @@ -27,9 +27,13 @@ async def fetch_multiple_currencies(): """Buscar taxas de câmbio concorrentemente.""" print("Exemplo 2: Buscando taxas de câmbio concorrentemente") - # Buscar taxa do USD (nota: você precisaria implementar async multi-símbolo - # para isso ser verdadeiramente concorrente para diferentes símbolos) - df = await currency.async_get("USD", start="2024-01-01", end="2024-12-31") + # Buscar múltiplos símbolos em paralelo + df = await currency.async_get( + ["USD", "EUR"], + start="2024-01-01", + end="2024-12-31", + side="both", + ) print("Busca de câmbio assíncrona concluída") print(df.head()) print() @@ -78,9 +82,11 @@ async def main(): await concurrent_operations() except Exception as e: print(f"Erro: {type(e).__name__}: {e}") + finally: + await http.aclose_async_client() if __name__ == "__main__": # Executar os exemplos assíncronos - # Nota: Isso requer Python 3.7+ com asyncio + # Nota: o pacote requer Python 3.10+ asyncio.run(main()) diff --git a/examples/currency_exchange.py b/examples/currency_exchange.py index 0165219..274c92d 100644 --- a/examples/currency_exchange.py +++ b/examples/currency_exchange.py @@ -5,7 +5,8 @@ sistema PTAX do Banco Central (cotações diárias de câmbio). """ -from datetime import datetime +import datetime as dt + from bcb import currency # Buscar uma única moeda (USD) @@ -60,11 +61,9 @@ # Limpar o cache se executando múltiplas requisições currency.clear_cache() -print("Cache limpo para requisições fresgas") +print("Cache limpo para requisições frescas") # Obter taxa de câmbio de hoje (aproximada) -import datetime as dt - today = dt.date.today() today_rates = currency.get("USD", start=today - dt.timedelta(days=30), end=today) print("\nTaxas de Câmbio Recentes do USD") diff --git a/examples/sgs_time_series.py b/examples/sgs_time_series.py index 0faee45..790020a 100644 --- a/examples/sgs_time_series.py +++ b/examples/sgs_time_series.py @@ -5,7 +5,6 @@ do SGS (Sistema Gerenciador de Séries Temporais) do Banco Central. """ -import pandas as pd from bcb import sgs # Buscar uma única série temporal @@ -23,7 +22,9 @@ # Buscar múltiplas séries temporais de uma vez # SELIC (1) e IPCA (433) -multi_series = sgs.get([("SELIC", 1), ("IPCA", 433)], start="2023-01-01", end="2024-12-31") +multi_series = sgs.get( + [("SELIC", 1), ("IPCA", 433)], start="2023-01-01", end="2024-12-31" +) print("Múltiplas Séries Temporais (SELIC + IPCA)") print(multi_series.head()) print() From cbcc88256739e86d733eb138342e5c02f4d07784 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 18:48:54 -0300 Subject: [PATCH 14/14] Expand quality gates (#56) --- .github/workflows/dependency-audit.yml | 31 ++ .github/workflows/integration.yml | 29 ++ .github/workflows/lint.yml | 2 +- .github/workflows/sphinx.yml | 2 +- .github/workflows/test.yml | 2 +- bcb/currency.py | 6 +- bcb/sgs/__init__.py | 7 +- pyproject.toml | 4 +- tests/conftest.py | 2 +- uv.lock | 506 ++++++++++++------------- 10 files changed, 323 insertions(+), 268 deletions(-) create mode 100644 .github/workflows/dependency-audit.yml create mode 100644 .github/workflows/integration.yml diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..c0e7d58 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,31 @@ +name: Dependency Audit + +on: + workflow_dispatch: + schedule: + - cron: "0 10 * * 1" + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Export locked dependencies + run: >- + uv export --frozen --format requirements-txt --all-groups + --no-emit-project --no-hashes -o requirements-audit.txt + + - name: Run dependency audit + run: uvx pip-audit --requirement requirements-audit.txt diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..9c54e95 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,29 @@ +name: Integration Tests + +on: + workflow_dispatch: + schedule: + - cron: "0 9 * * 1" + +permissions: + contents: read + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --frozen --group test + + - name: Run integration tests + run: uv run pytest -m integration diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fbe69fc..80bcec5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: run: uv python install 3.10 - name: Install dependencies - run: uv sync --group dev + run: uv sync --frozen --group dev - name: Check formatting run: uv run ruff format --check bcb/ tests/ diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml index 837a9bd..53b4bdb 100644 --- a/.github/workflows/sphinx.yml +++ b/.github/workflows/sphinx.yml @@ -20,7 +20,7 @@ jobs: run: uv python install 3.10 - name: Install dependencies - run: uv sync --group docs + run: uv sync --frozen --group docs - name: Build docs run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b15378..3918a95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --group test --group dev + run: uv sync --frozen --group test --group dev - name: Run tests run: uv run pytest --cov=bcb --cov-report=xml -m "not integration" diff --git a/bcb/currency.py b/bcb/currency.py index bf82cd7..8be5cf6 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -528,7 +528,7 @@ def _get_symbol( df1 = df.set_index("Date") n = ["bid", "ask"] df1 = df1[n] - tuples = list(zip([symbol] * len(n), n)) + tuples = list(zip([symbol] * len(n), n, strict=True)) df1.columns = pd.MultiIndex.from_tuples(tuples) return df1 @@ -867,7 +867,7 @@ async def _async_get_symbol( df1 = df.set_index("Date") n = ["bid", "ask"] df1 = df1[n] - tuples = list(zip([symbol] * len(n), n)) + tuples = list(zip([symbol] * len(n), n, strict=True)) df1.columns = pd.MultiIndex.from_tuples(tuples) return df1 @@ -927,7 +927,7 @@ async def async_get( *[_async_get_symbol_text(symbol, start, end) for symbol in symbols], return_exceptions=True, ) - for symbol, text in zip(symbols, texts): + for symbol, text in zip(symbols, texts, strict=True): if isinstance(text, CurrencyNotFoundError): continue if isinstance(text, BaseException): diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 8934d14..0e7837d 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -511,14 +511,17 @@ async def async_get( ) if output == "text": - results: Dict[int, str] = {c.value: t for c, t in zip(code_list, texts)} + results: Dict[int, str] = { + c.value: t for c, t in zip(code_list, texts, strict=True) + } values = list(results.values()) if len(values) == 1: return values[0] return results dfs = [ - _format_df(pd.read_json(StringIO(t)), c, freq) for c, t in zip(code_list, texts) + _format_df(pd.read_json(StringIO(t)), c, freq) + for c, t in zip(code_list, texts, strict=True) ] if len(dfs) == 1: return dfs[0] diff --git a/pyproject.toml b/pyproject.toml index 16422d8..16e0dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ omit = ["tests/*"] [tool.coverage.report] show_missing = true -fail_under = 75 +fail_under = 80 [tool.mypy] python_version = "3.10" @@ -66,7 +66,7 @@ line-length = 88 target-version = "py310" [tool.ruff.lint] -select = ["E", "W", "F"] +select = ["E", "W", "F", "B904", "B905", "RUF100"] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] diff --git a/tests/conftest.py b/tests/conftest.py index 005a653..e50152e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,7 +57,7 @@ def make_currency_list_csv( header = "Codigo;Nome;Simbolo;CodPais;NomePais;Tipo;DataExclusao\n" rows = [] - for symbol, code in zip(symbols, codes): + for symbol, code in zip(symbols, codes, strict=True): rows.append(f"{code};{symbol.upper()} CURRENCY;{symbol};999;COUNTRY;A;\n") return header + "".join(rows) diff --git a/uv.lock b/uv.lock index c5fd0df..2404011 100644 --- a/uv.lock +++ b/uv.lock @@ -885,11 +885,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1212,7 +1212,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.17.0" +version = "2.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1235,9 +1235,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/a0/eb3c511f54df7b54ca5fc7bff3f4d2277d69052d6a7f521643dfed5279d6/jupyter_server-2.19.0.tar.gz", hash = "sha256:1731236bc32b680223e1ceb9d68209a845203475012ef68773a81434b46a31a7", size = 754561, upload-time = "2026-05-29T11:21:26.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/d2881e68894cecdcd05912a9c585cfb776ef1fb38b62c8dba98f12ab3adc/jupyter_server-2.19.0-py3-none-any.whl", hash = "sha256:cb76591b76d7093584c2ad2ae72ac3d58614a4b597507a1bb04e1f9f683cf9ea", size = 392244, upload-time = "2026-05-29T11:21:23.871Z" }, ] [[package]] @@ -1255,7 +1255,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.5" +version = "4.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -1273,9 +1273,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/2d/953a5612a34a3c799a62566a548e711d103f631672fd49650e0f2de80870/jupyterlab-4.5.5.tar.gz", hash = "sha256:eac620698c59eb810e1729909be418d9373d18137cac66637141abba613b3fda", size = 23968441, upload-time = "2026-02-23T18:57:34.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/74/089613e6099e851a6130816f2df592c839d8565f8746a701edada05a33e4/jupyterlab-4.5.8.tar.gz", hash = "sha256:af54d7242cc689a1e6c3ad213cc9b6d9781787d9ec67c52ec9a8f4707088cadd", size = 23994076, upload-time = "2026-06-04T12:32:12.906Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/52/372d3494766d690dfdd286871bf5f7fb9a6c61f7566ccaa7153a163dd1df/jupyterlab-4.5.5-py3-none-any.whl", hash = "sha256:a35694a40a8e7f2e82f387472af24e61b22adcce87b5a8ab97a5d9c486202a6d", size = 12446824, upload-time = "2026-02-23T18:57:30.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/56a400100559cbf154a23cd29989261941ae5c9f743898fc10e8a5508b7c/jupyterlab-4.5.8-py3-none-any.whl", hash = "sha256:7d514c856d0d607601ec7692374da4f26e2aaf3b6e7cd363136b422a50588d6c", size = 12449443, upload-time = "2026-06-04T12:32:08.442Z" }, ] [[package]] @@ -1518,126 +1518,120 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, - { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, - { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, - { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, - { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, - { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, - { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, - { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, - { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, + { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, + { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, ] [[package]] @@ -1814,14 +1808,14 @@ wheels = [ [[package]] name = "mistune" -version = "3.2.0" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] [[package]] @@ -1896,7 +1890,7 @@ wheels = [ [[package]] name = "nbconvert" -version = "7.17.0" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -1914,9 +1908,9 @@ dependencies = [ { name = "pygments" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] @@ -1945,7 +1939,7 @@ wheels = [ [[package]] name = "notebook" -version = "7.5.4" +version = "7.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, @@ -1954,9 +1948,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/08/9d446fbb49f95de316ea6d7f25d0a4bc95117dd574e35f405895ac706f29/notebook-7.5.4.tar.gz", hash = "sha256:b928b2ba22cb63aa83df2e0e76fe3697950a0c1c4a41b84ebccf1972b1bb5771", size = 14167892, upload-time = "2026-02-24T14:13:56.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/c4/f71f8716f2903e9e817a47f534b9fd84831e155e2acb32c26691c8e06243/notebook-7.5.7.tar.gz", hash = "sha256:d6d59288a25303b25e1dcb71e9b017ec3a785f7d92f38b9bc288ca1970d5b0a8", size = 14171612, upload-time = "2026-06-04T18:33:45.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/01/05e5387b53e0f549212d5eff58845886f3827617b5c9409c966ddc07cb6d/notebook-7.5.4-py3-none-any.whl", hash = "sha256:860e31782b3d3a25ca0819ff039f5cf77845d1bf30c78ef9528b88b25e0a9850", size = 14578014, upload-time = "2026-02-24T14:13:52.274Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4d/b3347f7073a377273531efe4ffc738fc910e93718fd2838c7ebf6736c6af/notebook-7.5.7-py3-none-any.whl", hash = "sha256:1f95f79d117e47d20b5555b5c85a397d2cfecf136978aaab767cf0314b09165b", size = 14583767, upload-time = "2026-06-04T18:33:40.987Z" }, ] [[package]] @@ -2323,100 +2317,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -2533,11 +2527,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2551,7 +2545,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2562,9 +2556,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -2866,7 +2860,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2874,9 +2868,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -3302,21 +3296,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] [[package]] @@ -3357,11 +3349,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]