diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2403046 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: [ '*' ] + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + python-dependencies: + patterns: [ '*' ] diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index c93df5f..122f049 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -1,34 +1,93 @@ -# These workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Upload Python Package on: release: - types: [ created ] + types: [ published ] + +permissions: + contents: read jobs: - deploy: + build: + name: Build and verify distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Check out release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.x' + python-version: '3.14' + cache: pip - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install --upgrade setuptools wheel twine readme_renderer[md] - pip install -r requirements.txt + python -m pip install uv==0.12.15 + uv sync --locked --extra dev - - name: Build and publish + - name: Verify tag and package versions env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + uv run python - <<'PY' + import os + import api2ch + + expected_tag = f'v{api2ch.__version__}' + release_tag = os.environ['RELEASE_TAG'] + if release_tag != expected_tag: + raise SystemExit( + f'Release tag {release_tag!r} does not match package version {api2ch.__version__!r}' + ) + PY + + - name: Build distributions + run: uv run python -m build + + - name: Check distributions + run: uv run python -m twine check --strict dist/* + + - name: Test wheel installation + run: | + python -m venv "${RUNNER_TEMP}/api2ch-wheel" + "${RUNNER_TEMP}/api2ch-wheel/bin/python" -m pip install dist/*.whl + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/api2ch-wheel/bin/python" -c "import api2ch; print(api2ch.__version__)" + + - name: Test source distribution installation run: | - python setup.py sdist bdist_wheel - twine upload dist/* + python -m venv "${RUNNER_TEMP}/api2ch-sdist" + "${RUNNER_TEMP}/api2ch-sdist/bin/python" -m pip install dist/*.tar.gz + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/api2ch-sdist/bin/python" -c "import api2ch; print(api2ch.__version__)" + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/api2ch + permissions: + id-token: write + + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1125370..7514719 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,45 +1,167 @@ name: Python Tests -on: [ push, pull_request ] +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + inputs: + live_tests: + description: Run tests against the live 2ch API + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + test: + name: Python 3.14 / ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ 3.6, 3.7, 3.8, 3.9, '3.10' ] - - env: - OS: ${{ matrix.os }} - PYTHON: ${{ matrix.python-version }} + os: [ macos-latest, windows-latest ] steps: - - uses: actions/checkout@v2 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: ${{ matrix.python-version }} + python-version: '3.14' + cache: pip - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt + python -m pip install uv==0.12.15 + uv sync --locked --extra dev + + - name: Check formatting and lint + run: | + uv run ruff format --check api2ch tests + uv run ruff check api2ch tests + + - name: Check types + run: uv run mypy api2ch + + - name: Run offline tests + run: uv run pytest tests/ -vl -m "not integration" + + coverage: + name: Python 3.14 / ubuntu-latest + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Lint with flake8 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + cache: pip + + - name: Install dependencies run: | - # Stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + python -m pip install uv==0.12.15 + uv sync --locked --extra dev - - name: Test with pytest + - name: Check formatting, lint and types run: | - pytest -vl --cov=./ --cov-report=xml + uv run ruff format --check api2ch tests + uv run ruff check api2ch tests + uv run mypy api2ch + + - name: Run offline tests + run: uv run pytest tests/ -vl -m "not integration" --cov=api2ch --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: - env_vars: OS,PYTHON + use_oidc: true + fail_ci_if_error: true + files: coverage.xml flags: unittests + + package: + name: Build package + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + cache: pip + + - name: Install dependencies + run: | + python -m pip install uv==0.12.15 + uv sync --locked --extra dev + + - name: Build distributions + run: uv run python -m build + + - name: Check distributions + run: uv run python -m twine check --strict dist/* + + - name: Test wheel installation + shell: bash + run: | + python -m venv "${RUNNER_TEMP}/api2ch-wheel" + "${RUNNER_TEMP}/api2ch-wheel/bin/python" -m pip install dist/*.whl + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/api2ch-wheel/bin/python" -c "import api2ch; print(api2ch.__version__)" + + - name: Test source distribution installation + shell: bash + run: | + python -m venv "${RUNNER_TEMP}/api2ch-sdist" + "${RUNNER_TEMP}/api2ch-sdist/bin/python" -m pip install dist/*.tar.gz + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/api2ch-sdist/bin/python" -c "import api2ch; print(api2ch.__version__)" + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + + integration: + name: Live API tests + if: github.event_name == 'workflow_dispatch' && inputs.live_tests + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + cache: pip + + - name: Install dependencies + run: | + python -m pip install uv==0.12.15 + uv sync --locked --extra dev + + - name: Run live tests + run: uv run pytest tests/ -vl -m integration diff --git a/.gitignore b/.gitignore index 3efd499..7c7fd20 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ MANIFEST # Installer logs pip-log.txt pip-delete-this-directory.txt +pip-*/ # Unit test / coverage reports htmlcov/ @@ -50,6 +51,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +pytest-of-*/ # Translations *.mo @@ -104,6 +106,7 @@ celerybeat.pid # Environments .env .venv +.venv*/ env/ venv/ ENV/ diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2ee44f2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,22 @@ +# Publishing + +Releases are published to PyPI by GitHub Actions. The workflow builds and checks the wheel and source distribution first, then publishes those exact files with PyPI Trusted Publishing. No PyPI password or API token is stored in GitHub. + +## One-time setup + +1. Create a `pypi` environment in the GitHub repository and add required reviewers to protect it. +2. Add a Trusted Publisher for `api2ch` in PyPI with these values: + - Owner: `uburuntu` + - Repository: `api2ch` + - Workflow: `pythonpublish.yml` + - Environment: `pypi` + +## Release + +1. Set `api2ch.__version__` to the release version and merge the change. +2. Run the manual live API workflow and confirm that the current contract checks pass. +3. Create a GitHub release whose tag is `v` followed by the same version, for example `v2.0.0`. +4. Publish the GitHub release. +5. Review the completed build job and approve the `pypi` environment deployment. + +The workflow stops before publishing if the tag and package versions do not match, or if either distribution fails its metadata or installation checks. diff --git a/api2ch/__init__.py b/api2ch/__init__.py index 3d24f6f..d1a6b48 100644 --- a/api2ch/__init__.py +++ b/api2ch/__init__.py @@ -1,22 +1,47 @@ -"""Async API Wrapper for 2ch Imageboard with typings""" +"""Typed synchronous and asynchronous clients for the read-only 2ch API.""" -from .api import Api2ch, Api2chAsync, Api2chBase, Api2chError -from .config import API_BASE, BOARDS, api_mirrors, downloads_dir -from .helpers import download_thread_media -from .models.auxiliary import BannedStatus, NewsAbu, Tag, Top -from .models.base import Base, Request, Response -from .models.board import Board, BoardInfo, BoardInfoBase, BoardInfoMini -from .models.file import File -from .models.post import Post -from .models.request import RequestBoards, RequestBoardsByTypes, RequestCatalog, RequestCatalogByDate, RequestPage, RequestSinglePost, \ - RequestThread, RequestThreadPostsByNum, RequestThreadPostsByPost, RequestThreads -from .models.response import ResponseBoards, ResponseBoardsByTypes, ResponseCatalog, ResponseCatalogByDate, ResponsePage, \ - ResponseSinglePost, ResponseThread, ResponseThreadPostsByNum, ResponseThreadPostsByPost, ResponseThreads -from .models.thread import Thread, ThreadWithStats -from .utils import clear_html, convert_html, parse_url +from api2ch.api import ( + Api2ch, + Api2chAsync, + Api2chError, + Api2chHTTPError, + Api2chResponseError, + Api2chTimeoutError, + Api2chTransportError, + Api2chUpstreamError, + Api2chValidationError, +) +from api2ch.config import API_BASE, api_mirrors, downloads_dir +from api2ch.downloads import DownloadResult, download_file, download_file_async +from api2ch.helpers import ThreadDownloadResult, download_thread_media +from api2ch.models import * # noqa: F403 +from api2ch.utils import clear_html, convert_html, parse_url, prettify_bytes -__author__ = 'uburuntu' -__email__ = 'github@rmbk.me' +__author__ = "uburuntu" +__email__ = "github@rmbk.me" +__license__ = "MIT" +__version__ = "2.0.0" -__license__ = 'MIT' -__version__ = '1.2.1' +__all__ = [ + "API_BASE", + "Api2ch", + "Api2chAsync", + "Api2chError", + "Api2chHTTPError", + "Api2chResponseError", + "Api2chTimeoutError", + "Api2chTransportError", + "Api2chUpstreamError", + "Api2chValidationError", + "DownloadResult", + "ThreadDownloadResult", + "api_mirrors", + "clear_html", + "convert_html", + "download_file", + "download_file_async", + "download_thread_media", + "downloads_dir", + "parse_url", + "prettify_bytes", +] diff --git a/api2ch/api.py b/api2ch/api.py index 3e967db..b475890 100644 --- a/api2ch/api.py +++ b/api2ch/api.py @@ -1,176 +1,441 @@ -import json -from typing import Union - -import aiohttp -import requests - -from api2ch.config import API_BASE -from api2ch.models.request import Request, RequestBoards, RequestBoardsByTypes, RequestCatalog, RequestCatalogByDate, RequestPage, \ - RequestSinglePost, RequestThread, RequestThreadPostsByNum, RequestThreadPostsByPost, RequestThreads -from api2ch.models.response import ResponseBoards, ResponseBoardsByTypes, ResponseCatalog, ResponseCatalogByDate, ResponsePage, \ - ResponseSinglePost, ResponseThread, ResponseThreadPostsByNum, ResponseThreadPostsByPost, ResponseThreads +import re +from collections.abc import Mapping +from typing import Any +from urllib.parse import urlparse + +import httpx +from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypeForm + +from api2ch.config import API_BASE, DEFAULT_TIMEOUT, USER_AGENT +from api2ch.models.auxiliary import ThreadInfo +from api2ch.models.board import Board +from api2ch.models.post import Post +from api2ch.models.response import ( + ResponseCatalog, + ResponsePage, + ResponseSinglePost, + ResponseThread, + ResponseThreadInfo, + ResponseThreadPostsByNum, + ResponseThreads, +) + +Json = dict[str, Any] | list[Any] | str | int | float | bool | None +_BOARD = re.compile(r"[a-z0-9_]+") class Api2chError(Exception): - def __init__(self, code: int, reason: str): + def __init__(self, message: str, *, code: int | None = None, url: str | None = None): + super().__init__(message) self.code = code - self.reason = reason + self.reason = message + self.url = url + + def __repr__(self) -> str: + prefix = f"[{self.code}] " if self.code is not None else "" + return f"{type(self).__name__}({prefix}{self.reason})" + + +class Api2chTransportError(Api2chError): + pass + + +class Api2chTimeoutError(Api2chTransportError): + pass + + +class Api2chHTTPError(Api2chError): + pass + + +class Api2chUpstreamError(Api2chError): + pass + + +class Api2chResponseError(Api2chError): + pass + - def __repr__(self): - return f'[{self.code}] {self.reason}' +class Api2chValidationError(Api2chResponseError): + pass + + +def _board(value: str) -> str: + if _BOARD.fullmatch(value) is None: + raise ValueError(f"invalid board id: {value!r}") + return value + + +def _positive(value: str | int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be a positive integer") from error + if parsed < 1: + raise ValueError(f"{name} must be a positive integer") + return parsed + + +def _page(value: str | int) -> str: + if value == "index": + return "index" + parsed = int(value) + if parsed < 0: + raise ValueError("page must be 'index' or a non-negative integer") + return str(parsed) + + +def _decode(response: httpx.Response) -> Json: + try: + response.raise_for_status() + except httpx.HTTPStatusError as error: + raise Api2chHTTPError( + f"upstream returned HTTP {response.status_code}", + code=response.status_code, + url=str(response.url), + ) from error + try: + result: Json = response.json() + except ValueError as error: + raise Api2chResponseError( + "upstream returned malformed JSON", url=str(response.url) + ) from error + if isinstance(result, Mapping) and result.get("result") == 0: + details = result.get("error") + if isinstance(details, Mapping): + code = details.get("code") + message = details.get("message", "upstream request failed") + raise Api2chUpstreamError( + str(message), + code=code if isinstance(code, int) else None, + url=str(response.url), + ) + raise Api2chUpstreamError("upstream request failed", url=str(response.url)) + return result + + +def _validate[T](data: Json, expected: TypeForm[T], url: str) -> T: + try: + return TypeAdapter(expected).validate_python(data) + except ValidationError as error: + raise Api2chValidationError( + "upstream response did not match the expected schema", url=url + ) from error class Api2chBase: - """ - Docs: https://2ch.hk/api/res/1.html - """ - api_base = API_BASE - - def __init__(self, raw_results: bool = False): - self.raw = raw_results - - def set_api_base(self, api_base: str): - """ - List of possible mirrors for API base: - - https://2ch.hk - - https://2ch.pm - - https://2ch.re - - https://2ch.tf - - https://2ch.wf - - https://2ch.yt - - https://2-ch.so - """ - self.api_base = api_base + def __init__(self, *, base_url: str = API_BASE) -> None: + self.api_base = self._normalize_base_url(base_url) + + @staticmethod + def _normalize_base_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("base_url must be an absolute HTTP(S) origin") + return value.rstrip("/") + + def set_api_base(self, api_base: str) -> Api2chBase: + self.api_base = self._normalize_base_url(api_base) return self - def reset_api_base(self): + def reset_api_base(self) -> Api2chBase: self.api_base = API_BASE return self + def _url(self, path: str) -> str: + return f"{self.api_base}/{path.lstrip('/')}" + + +class _RawSync: + def __init__(self, api: Api2ch) -> None: + self._api = api + + def get(self, path: str) -> Json: + return self._api._get_json(path) + + def boards(self) -> Json: + return self.get("/api/mobile/v2/boards") + + def thread(self, board: str, thread: str | int) -> Json: + return self.get(f"/{_board(board)}/res/{_positive(thread, 'thread')}.json") + + def threads(self, board: str) -> Json: + return self.get(f"/{_board(board)}/threads.json") + + def catalog(self, board: str) -> Json: + return self.get(f"/{_board(board)}/catalog.json") + + def catalog_by_date(self, board: str) -> Json: + return self.get(f"/{_board(board)}/catalog_num.json") + + def page(self, board: str, page: str | int = "index") -> Json: + return self.get(f"/{_board(board)}/{_page(page)}.json") + + def thread_info(self, board: str, thread: str | int) -> Json: + return self.get(f"/api/mobile/v2/info/{_board(board)}/{_positive(thread, 'thread')}") + + def posts_after(self, board: str, thread: str | int, num: str | int) -> Json: + return self.get( + f"/api/mobile/v2/after/{_board(board)}/{_positive(thread, 'thread')}/" + f"{_positive(num, 'num')}" + ) + + def post(self, board: str, num: str | int) -> Json: + return self.get(f"/api/mobile/v2/post/{_board(board)}/{_positive(num, 'num')}") + class Api2ch(Api2chBase): + def __init__( + self, + *, + base_url: str = API_BASE, + timeout: float | httpx.Timeout = DEFAULT_TIMEOUT, + headers: Mapping[str, str] | None = None, + client: httpx.Client | None = None, + ) -> None: + super().__init__(base_url=base_url) + self._timeout = timeout + self._headers = {"User-Agent": USER_AGENT, **(headers or {})} + self._client = client + self._owns_client = client is None + self._closed = False + self.raw = _RawSync(self) + @property - def session(self) -> requests.Session: - session = getattr(self, '_session', None) - if session is None: - session = requests.Session() - setattr(self, '_session', session) - return session - - def close(self): - """ - Closes session - :return: None - """ - self.session.close() - - def __enter__(self): + def client(self) -> httpx.Client: + if self._closed: + raise RuntimeError("client is closed") + if self._client is None: + self._client = httpx.Client( + timeout=self._timeout, + headers=self._headers, + follow_redirects=True, + ) + return self._client + + def close(self) -> None: + if not self._closed and self._owns_client and self._client is not None: + self._client.close() + self._closed = True + + def __enter__(self) -> Api2ch: return self - def __exit__(self, exc_type, exc_val, exc_tb): - return self.close() - - def request(self, request: Request): - response = self.session.get(request.url(self.api_base)) - if response.status_code != 200: - raise Api2chError(response.status_code, response.reason) - result = json.loads(response.content) - if self.raw: - return result - result = request.__returning__.parse_obj(result) - result.request = request + def __exit__(self, *args: object) -> None: + self.close() + + def _get_json(self, path: str) -> Json: + url = self._url(path) + try: + return _decode(self.client.get(url)) + except httpx.TimeoutException as error: + raise Api2chTimeoutError("upstream request timed out", url=url) from error + except httpx.TransportError as error: + raise Api2chTransportError("upstream transport failed", url=url) from error + + def _get[T](self, path: str, expected: TypeForm[T]) -> T: + url = self._url(path) + return _validate(self._get_json(path), expected, url) + + def boards(self) -> list[Board]: + return self._get("/api/mobile/v2/boards", list[Board]) + + def boards_by_types(self) -> dict[str, list[Board]]: + result: dict[str, list[Board]] = {} + for board in self.boards(): + result.setdefault(board.category, []).append(board) return result - def thread(self, board: str, thread: Union[str, int]) -> ResponseThread: - return RequestThread(api=self, board=board, thread=thread).do() + def thread(self, board: str, thread: str | int) -> ResponseThread: + return self._get(f"/{_board(board)}/res/{_positive(thread, 'thread')}.json", ResponseThread) def threads(self, board: str) -> ResponseThreads: - return RequestThreads(api=self, board=board).do() + return self._get(f"/{_board(board)}/threads.json", ResponseThreads) def catalog(self, board: str) -> ResponseCatalog: - return RequestCatalog(api=self, board=board).do() + return self._get(f"/{_board(board)}/catalog.json", ResponseCatalog) - def catalog_by_date(self, board: str) -> ResponseCatalogByDate: - return RequestCatalogByDate(api=self, board=board).do() + def catalog_by_date(self, board: str) -> ResponseCatalog: + return self._get(f"/{_board(board)}/catalog_num.json", ResponseCatalog) - def page(self, board: str, page: Union[str, int]) -> ResponsePage: - return RequestPage(api=self, board=board, page=page).do() + def page(self, board: str, page: str | int = "index") -> ResponsePage: + return self._get(f"/{_board(board)}/{_page(page)}.json", ResponsePage) - def boards(self) -> ResponseBoards: - return RequestBoards(api=self).do() + def thread_info(self, board: str, thread: str | int) -> ThreadInfo: + response = self._get( + f"/api/mobile/v2/info/{_board(board)}/{_positive(thread, 'thread')}", + ResponseThreadInfo, + ) + if response.thread is None: + raise Api2chResponseError("upstream response omitted thread information") + return response.thread - def boards_by_types(self) -> ResponseBoardsByTypes: - return RequestBoardsByTypes(api=self).do() + def posts_after( + self, board: str, thread: str | int, num: str | int + ) -> ResponseThreadPostsByNum: + return self._get( + f"/api/mobile/v2/after/{_board(board)}/" + f"{_positive(thread, 'thread')}/{_positive(num, 'num')}", + ResponseThreadPostsByNum, + ) - def thread_posts_by_num(self, board: str, thread: Union[str, int], num: Union[str, int]) -> ResponseThreadPostsByNum: - return RequestThreadPostsByNum(api=self, board=board, thread=thread, num=num).do() + def post(self, board: str, num: str | int) -> Post: + response = self._get( + f"/api/mobile/v2/post/{_board(board)}/{_positive(num, 'num')}", + ResponseSinglePost, + ) + if response.post is None: + raise Api2chResponseError("upstream response omitted the post") + return response.post - def thread_posts_by_post(self, board: str, thread: Union[str, int], post: Union[str, int]) -> ResponseThreadPostsByPost: - return RequestThreadPostsByPost(api=self, board=board, thread=thread, post=post).do() + thread_posts_by_num = posts_after + single_post = post - def single_post(self, board: str, post: Union[str, int]) -> ResponseSinglePost: - return RequestSinglePost(api=self, board=board, post=post).do() +class _RawAsync: + def __init__(self, api: Api2chAsync) -> None: + self._api = api -class Api2chAsync(Api2chBase): - @property - def session(self) -> aiohttp.ClientSession: - session = getattr(self, '_session', None) - if session is None: - session = aiohttp.ClientSession() - setattr(self, '_session', session) - return session - - async def close(self): - """ - Closes session - :return: None - """ - await self.session.close() - - async def __aenter__(self): - return self + async def get(self, path: str) -> Json: + return await self._api._get_json(path) - async def __aexit__(self, exc_type, exc_val, exc_tb): - return await self.close() + async def boards(self) -> Json: + return await self.get("/api/mobile/v2/boards") - async def request(self, request: Request): - async with self.session.get(request.url(self.api_base)) as response: - if response.status != 200: - raise Api2chError(response.status, response.reason) - result = json.loads(await response.read()) - if self.raw: - return result - result = request.__returning__.parse_obj(result) - result.request = request - return result + async def thread(self, board: str, thread: str | int) -> Json: + return await self.get(f"/{_board(board)}/res/{_positive(thread, 'thread')}.json") - async def thread(self, board: str, thread: Union[str, int]) -> ResponseThread: - return await RequestThread(api=self, board=board, thread=thread).do_async() + async def threads(self, board: str) -> Json: + return await self.get(f"/{_board(board)}/threads.json") - async def threads(self, board: str) -> ResponseThreads: - return await RequestThreads(api=self, board=board).do_async() + async def catalog(self, board: str) -> Json: + return await self.get(f"/{_board(board)}/catalog.json") - async def catalog(self, board: str) -> ResponseCatalog: - return await RequestCatalog(api=self, board=board).do_async() + async def catalog_by_date(self, board: str) -> Json: + return await self.get(f"/{_board(board)}/catalog_num.json") - async def catalog_by_date(self, board: str) -> ResponseCatalogByDate: - return await RequestCatalogByDate(api=self, board=board).do_async() + async def page(self, board: str, page: str | int = "index") -> Json: + return await self.get(f"/{_board(board)}/{_page(page)}.json") - async def page(self, board: str, page: Union[str, int]) -> ResponsePage: - return await RequestPage(api=self, board=board, page=page).do_async() + async def thread_info(self, board: str, thread: str | int) -> Json: + return await self.get(f"/api/mobile/v2/info/{_board(board)}/{_positive(thread, 'thread')}") - async def boards(self) -> ResponseBoards: - return await RequestBoards(api=self).do_async() + async def posts_after(self, board: str, thread: str | int, num: str | int) -> Json: + return await self.get( + f"/api/mobile/v2/after/{_board(board)}/{_positive(thread, 'thread')}/" + f"{_positive(num, 'num')}" + ) - async def boards_by_types(self) -> ResponseBoardsByTypes: - return await RequestBoardsByTypes(api=self).do_async() + async def post(self, board: str, num: str | int) -> Json: + return await self.get(f"/api/mobile/v2/post/{_board(board)}/{_positive(num, 'num')}") - async def thread_posts_by_num(self, board: str, thread: Union[str, int], num: Union[str, int]) -> ResponseThreadPostsByNum: - return await RequestThreadPostsByNum(api=self, board=board, thread=thread, num=num).do_async() - async def thread_posts_by_post(self, board: str, thread: Union[str, int], post: Union[str, int]) -> ResponseThreadPostsByPost: - return await RequestThreadPostsByPost(api=self, board=board, thread=thread, post=post).do_async() +class Api2chAsync(Api2chBase): + def __init__( + self, + *, + base_url: str = API_BASE, + timeout: float | httpx.Timeout = DEFAULT_TIMEOUT, + headers: Mapping[str, str] | None = None, + client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(base_url=base_url) + self._timeout = timeout + self._headers = {"User-Agent": USER_AGENT, **(headers or {})} + self._client = client + self._owns_client = client is None + self._closed = False + self.raw = _RawAsync(self) + + @property + def client(self) -> httpx.AsyncClient: + if self._closed: + raise RuntimeError("client is closed") + if self._client is None: + self._client = httpx.AsyncClient( + timeout=self._timeout, + headers=self._headers, + follow_redirects=True, + ) + return self._client + + async def close(self) -> None: + if not self._closed and self._owns_client and self._client is not None: + await self._client.aclose() + self._closed = True + + async def __aenter__(self) -> Api2chAsync: + return self - async def single_post(self, board: str, post: Union[str, int]) -> ResponseSinglePost: - return await RequestSinglePost(api=self, board=board, post=post).do_async() + async def __aexit__(self, *args: object) -> None: + await self.close() + + async def _get_json(self, path: str) -> Json: + url = self._url(path) + try: + return _decode(await self.client.get(url)) + except httpx.TimeoutException as error: + raise Api2chTimeoutError("upstream request timed out", url=url) from error + except httpx.TransportError as error: + raise Api2chTransportError("upstream transport failed", url=url) from error + + async def _get[T](self, path: str, expected: TypeForm[T]) -> T: + url = self._url(path) + return _validate(await self._get_json(path), expected, url) + + async def boards(self) -> list[Board]: + return await self._get("/api/mobile/v2/boards", list[Board]) + + async def boards_by_types(self) -> dict[str, list[Board]]: + result: dict[str, list[Board]] = {} + for board in await self.boards(): + result.setdefault(board.category, []).append(board) + return result + + async def thread(self, board: str, thread: str | int) -> ResponseThread: + return await self._get( + f"/{_board(board)}/res/{_positive(thread, 'thread')}.json", ResponseThread + ) + + async def threads(self, board: str) -> ResponseThreads: + return await self._get(f"/{_board(board)}/threads.json", ResponseThreads) + + async def catalog(self, board: str) -> ResponseCatalog: + return await self._get(f"/{_board(board)}/catalog.json", ResponseCatalog) + + async def catalog_by_date(self, board: str) -> ResponseCatalog: + return await self._get(f"/{_board(board)}/catalog_num.json", ResponseCatalog) + + async def page(self, board: str, page: str | int = "index") -> ResponsePage: + return await self._get(f"/{_board(board)}/{_page(page)}.json", ResponsePage) + + async def thread_info(self, board: str, thread: str | int) -> ThreadInfo: + response = await self._get( + f"/api/mobile/v2/info/{_board(board)}/{_positive(thread, 'thread')}", + ResponseThreadInfo, + ) + if response.thread is None: + raise Api2chResponseError("upstream response omitted thread information") + return response.thread + + async def posts_after( + self, board: str, thread: str | int, num: str | int + ) -> ResponseThreadPostsByNum: + return await self._get( + f"/api/mobile/v2/after/{_board(board)}/" + f"{_positive(thread, 'thread')}/{_positive(num, 'num')}", + ResponseThreadPostsByNum, + ) + + async def post(self, board: str, num: str | int) -> Post: + response = await self._get( + f"/api/mobile/v2/post/{_board(board)}/{_positive(num, 'num')}", + ResponseSinglePost, + ) + if response.post is None: + raise Api2chResponseError("upstream response omitted the post") + return response.post + + thread_posts_by_num = posts_after + single_post = post diff --git a/api2ch/config.py b/api2ch/config.py index ce4aaa3..6241d0a 100644 --- a/api2ch/config.py +++ b/api2ch/config.py @@ -1,29 +1,11 @@ from pathlib import Path -# In case of apocalypse - use any of `api_mirrors` -API_BASE = 'https://2ch.hk' - -hostname_mirrors = ( - '2ch.hk', - '2ch.pm', - '2ch.re', - '2ch.tf', - '2ch.wf', - '2ch.yt', - '2-ch.so', -) - -api_mirrors = tuple(f'https://{hostname}' for hostname in hostname_mirrors) - -BOARDS = ( - 'b', 'po', 'vg', 'fag', 'news', 'sex', 'rf', 'hw', 'v', '2d', 'by', 'wrk', 'soc', 'ftb', 'pr', 'psy', 'kpop', 'dr', 'sp', 'mov', 'au', - 'mobi', 'spc', 'fiz', 'hi', 'a', 'biz', 'em', 'ma', 'd', 'mus', 'pa', 'cg', 'gg', 'mu', 'fl', 'es', 'ga', 'fa', 'gsg', 'mg', 'un', 'fs', - 'bi', 'me', 'tes', 'di', 'fet', 'm', 's', 'td', 'e', 'hry', 'p', 'c', 're', 'w', 'wm', 'fd', 'zog', 't', 'rm', 'ch', 'tv', 'fg', 'gd', - 'alco', 'socionics', 'bg', 'hc', 'cc', 'mo', 'diy', 'r', 'vape', 'bo', 'mlp', 'mmo', 'wr', 'ho', 'asmr', 'math', 'hh', 'pvc', 'sf', 'vn', - 'dom', 'h', 'trv', 'out', 'sw', 'law', 'qtr4', 'se', 'brg', 'o', 'int', 'ruvn', 'ew', 'ne', 'sn', 'tr', 'wh', 'de', 'izd', 'aa', 'old', - 'kz', 'hv', 'wwe', 'whn', 'ph', 'ra', 'sci', 'wp', 'mc', 'ja', 'to', 'fur', 'web', 'br', 'gb', 'moba', 'abu', 'cul', 'media', 'guro', - 'ussr', 'jsf', 'ukr', 'ya', 'r34', 'wow', 'gabe', 'cute', '8', 'mlpr', 'ro', 'who', 'srv', 'electrach', 'ing', 'got', 'crypt', 'lap', 'smo', - 'hg', 'sad', 'fi', 'nvr', 'ind', 'ld', 'fem', 'vr', 'arg', 'char', 'pok', 'obr', 'catalog', 'api', -) - -downloads_dir = Path('./downloads_2ch') +API_BASE = "https://2ch.su" +DEFAULT_TIMEOUT = 30.0 +USER_AGENT = "api2ch/2.0.0 (+https://github.com/uburuntu/api2ch)" + +# Compatibility conveniences. Validation no longer relies on frozen board or +# mirror lists. +api_mirrors = ("https://2ch.su", "https://2ch.hk", "https://beta.2ch.hk") +hostname_mirrors = tuple(origin.removeprefix("https://") for origin in api_mirrors) +downloads_dir = Path("downloads_2ch") diff --git a/api2ch/downloads.py b/api2ch/downloads.py new file mode 100644 index 0000000..1c5171c --- /dev/null +++ b/api2ch/downloads.py @@ -0,0 +1,103 @@ +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +import anyio +import httpx + +from api2ch.config import API_BASE, DEFAULT_TIMEOUT, downloads_dir +from api2ch.models.file import File + + +@dataclass(frozen=True, slots=True) +class DownloadResult: + path: Path + bytes_written: int + skipped: bool = False + + +def _safe_name(value: str) -> str: + name = PurePosixPath(value.replace("\\", "/")).name + if name in {"", ".", ".."}: + raise ValueError("remote file has no safe filename") + return name + + +def _target(file: File, destination: str | Path | None) -> Path: + if destination is None: + destination = downloads_dir + target = Path(destination) + if (target.exists() and target.is_dir()) or (not target.exists() and not target.suffix): + target = target / _safe_name(file.original_name) + target.parent.mkdir(parents=True, exist_ok=True) + return target + + +def download_file( + file: File, + destination: str | Path | None = None, + *, + base_url: str = API_BASE, + skip_if_exists: bool = True, + client: httpx.Client | None = None, +) -> DownloadResult: + target = _target(file, destination) + if skip_if_exists and target.is_file(): + return DownloadResult(target, target.stat().st_size, skipped=True) + + own_client = client is None + active_client = client or httpx.Client(timeout=DEFAULT_TIMEOUT, follow_redirects=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".part", dir=target.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + size = 0 + try: + with active_client.stream("GET", file.url(base_url)) as response: + response.raise_for_status() + with temporary.open("wb") as stream: + for chunk in response.iter_bytes(): + size += stream.write(chunk) + temporary.replace(target) + return DownloadResult(target, size) + finally: + temporary.unlink(missing_ok=True) + if own_client: + active_client.close() + + +async def download_file_async( + file: File, + destination: str | Path | None = None, + *, + base_url: str = API_BASE, + skip_if_exists: bool = True, + client: httpx.AsyncClient | None = None, +) -> DownloadResult: + target = _target(file, destination) + if skip_if_exists and target.is_file(): + size = await anyio.to_thread.run_sync(lambda: target.stat().st_size) + return DownloadResult(target, size, skipped=True) + + own_client = client is None + active_client = client or httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, follow_redirects=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".part", dir=target.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + size = 0 + try: + async with active_client.stream("GET", file.url(base_url)) as response: + response.raise_for_status() + async with await anyio.open_file(temporary, "wb") as stream: + async for chunk in response.aiter_bytes(): + size += await stream.write(chunk) + await anyio.to_thread.run_sync(temporary.replace, target) + return DownloadResult(target, size) + finally: + await anyio.to_thread.run_sync(temporary.unlink, True) + if own_client: + await active_client.aclose() diff --git a/api2ch/helpers.py b/api2ch/helpers.py index 2194dc6..9752ef0 100644 --- a/api2ch/helpers.py +++ b/api2ch/helpers.py @@ -1,50 +1,44 @@ -import asyncio +from dataclasses import dataclass from pathlib import Path -from api2ch.api import Api2chAsync +from api2ch.api import Api2ch from api2ch.config import downloads_dir -from api2ch.models.file import File -from api2ch.utils import parse_url, prettify_bytes +from api2ch.downloads import DownloadResult, download_file +from api2ch.utils import parse_url -def download_thread_media( - url: str, - path: Path = downloads_dir, - with_thumbnails: bool = False, - skip_if_exists: bool = True, - concurrent_downloads: int = 10, -) -> str: - valid, board, thread_id = parse_url(url) - - if not valid: - return '0 Б' - - path.mkdir(parents=True, exist_ok=True) - if not path.is_dir(): - path = path.parent - path = path / f'{board}_{thread_id}' - path.mkdir(parents=True, exist_ok=True) - - async def download(): - s = asyncio.Semaphore(concurrent_downloads) +@dataclass(frozen=True, slots=True) +class ThreadDownloadResult: + files: tuple[DownloadResult, ...] - async def download_single(file: File): - async with s: - size_ = await file.download_async(path, skip_if_exists) - if with_thumbnails: - size_ += await file.download_thumbnail_async(path, skip_if_exists) - return size_ + @property + def bytes_written(self) -> int: + return sum(item.bytes_written for item in self.files if not item.skipped) - async with Api2chAsync() as api: - t = await api.thread(board, thread_id) - coros = [] - for p in t.posts: - for f in p.files: - coros.append(download_single(f)) - - return await asyncio.gather(*coros) - - loop = asyncio.get_event_loop() - size = sum(loop.run_until_complete(download())) - return prettify_bytes(size) +def download_thread_media( + url: str, + path: Path = downloads_dir, + *, + skip_if_exists: bool = True, +) -> ThreadDownloadResult: + valid, board, thread_id = parse_url(url) + if not valid: + raise ValueError("url is not a valid 2ch thread URL") + + destination = path / f"{board}_{thread_id}" + results: list[DownloadResult] = [] + with Api2ch() as api: + response = api.thread(board, thread_id) + for post in response.posts: + for file in post.files: + results.append( + download_file( + file, + destination, + base_url=api.api_base, + skip_if_exists=skip_if_exists, + client=api.client, + ) + ) + return ThreadDownloadResult(tuple(results)) diff --git a/api2ch/models/__init__.py b/api2ch/models/__init__.py index e69de29..81787b8 100644 --- a/api2ch/models/__init__.py +++ b/api2ch/models/__init__.py @@ -0,0 +1,36 @@ +from api2ch.models.auxiliary import BannedStatus, Icon, ThreadInfo, UpstreamError +from api2ch.models.base import Base +from api2ch.models.board import Board +from api2ch.models.file import File, FileType +from api2ch.models.post import Post +from api2ch.models.response import ( + ResponseCatalog, + ResponsePage, + ResponseSinglePost, + ResponseThread, + ResponseThreadInfo, + ResponseThreadPostsByNum, + ResponseThreads, +) +from api2ch.models.thread import Thread, ThreadSummary + +__all__ = [ + "BannedStatus", + "Base", + "Board", + "File", + "FileType", + "Icon", + "Post", + "ResponseCatalog", + "ResponsePage", + "ResponseSinglePost", + "ResponseThread", + "ResponseThreadInfo", + "ResponseThreadPostsByNum", + "ResponseThreads", + "Thread", + "ThreadInfo", + "ThreadSummary", + "UpstreamError", +] diff --git a/api2ch/models/auxiliary.py b/api2ch/models/auxiliary.py index cd75438..3e6fa1d 100644 --- a/api2ch/models/auxiliary.py +++ b/api2ch/models/auxiliary.py @@ -3,24 +3,6 @@ from api2ch.models.base import Base -class NewsAbu(Base): - date: str - num: int - subject: str - views: int - - -class Top(Base): - board: str - info: str - name: str - - -class Tag(Base): - board: str - tag: str - - class BannedStatus(IntEnum): nothing = 0 banned = 1 @@ -31,3 +13,14 @@ class Icon(Base): name: str num: int url: str + + +class UpstreamError(Base): + code: int + message: str + + +class ThreadInfo(Base): + num: int + timestamp: int + posts: int diff --git a/api2ch/models/base.py b/api2ch/models/base.py index af4aca7..467ca83 100644 --- a/api2ch/models/base.py +++ b/api2ch/models/base.py @@ -1,34 +1,7 @@ -from typing import Any, Optional - -from pydantic import BaseConfig, BaseModel, Extra +from pydantic import BaseModel, ConfigDict class Base(BaseModel): - api: Optional[Any] - - class Config(BaseConfig): - anystr_strip_whitespace = True - extra = Extra.allow - use_enum_values = True - - -class Response(Base): - request: Optional['Request'] - - -class Request(Base): - __returning__ = Response - - def url(self, base: str) -> str: - raise NotImplementedError - - def do(self, api=None) -> __returning__: - api = api or self.api - return api.request(self) - - async def do_async(self, api=None) -> __returning__: - api = api or self.api - return await api.request(self) - + """Base for the evolving upstream response models.""" -Response.update_forward_refs() + model_config = ConfigDict(extra="allow", populate_by_name=True) diff --git a/api2ch/models/board.py b/api2ch/models/board.py index 914e761..9aa9a53 100644 --- a/api2ch/models/board.py +++ b/api2ch/models/board.py @@ -1,69 +1,39 @@ -from typing import List, Optional - from pydantic import Field -from api2ch.models.auxiliary import NewsAbu, Top, Icon +from api2ch.models.auxiliary import Icon from api2ch.models.base import Base -class BoardInfoBase(Base): +class Board(Base): + id: str + name: str + category: str + info: str = "" + info_outer: str = "" + threads_per_page: int bump_limit: int + max_pages: int default_name: str - enable_dices: bool - enable_flags: bool - enable_icons: bool - enable_likes: bool enable_names: bool - enable_oekaki: bool - enable_posting: bool + enable_trips: bool + enable_subject: bool enable_sage: bool + enable_icons: bool + enable_flags: bool + enable_dices: bool enable_shield: bool - enable_subject: bool enable_thread_tags: bool - enable_trips: bool - - -class BoardInfoMini(BoardInfoBase): - category: str - icons: List[Icon] - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class BoardInfo(BoardInfoBase): - board: str = Field(alias='Board') - board_info: str = Field(alias='BoardInfo') - board_info_outer: str = Field(alias='BoardInfoOuter') - board_name: str = Field(alias='BoardName') - advert_bottom_image: str - advert_bottom_link: str - advert_mobile_image: Optional[str] - advert_mobile_link: Optional[str] - advert_top_image: str - advert_top_link: str - board_banner_image: str - board_banner_link: str - enable_images: bool - enable_video: bool + enable_posting: bool + enable_likes: bool + enable_oekaki: bool + file_types: list[str] = Field(default_factory=list) max_comment: int max_files_size: int - news_abu: List[NewsAbu] - top: List[Top] + tags: list[str] = Field(default_factory=list) + icons: list[Icon] = Field(default_factory=list) -class Board(Base): - bump_limit: int - category: str - default_name: str - enable_names: int - enable_sage: int - id: str - info: str - last_num: int - name: str - speed: int - threads: int - unique_posters: int +# Compatibility names for code that imported the 1.x model hierarchy. +BoardInfoBase = Board +BoardInfoMini = Board +BoardInfo = Board diff --git a/api2ch/models/file.py b/api2ch/models/file.py index f0ede79..dd38325 100644 --- a/api2ch/models/file.py +++ b/api2ch/models/file.py @@ -1,26 +1,44 @@ -from pathlib import Path -from typing import Optional +from enum import IntEnum +from pathlib import PurePosixPath -import aiofiles -import aiohttp -import requests -from pydantic import validator - -from api2ch.config import API_BASE, downloads_dir +from api2ch.config import API_BASE from api2ch.models.base import Base from api2ch.utils import prettify_bytes +class FileType(IntEnum): + none = 0 + jpeg = 1 + png = 2 + apng = 3 + gif = 4 + bmp = 5 + webm = 6 + mp3 = 7 + ogg = 8 + mp4 = 10 + sticker = 100 + + class File(Base): name: str - type: int - height: int - width: int path: str - size: int thumbnail: str - tn_height: int + type: int + size: int + width: int + height: int tn_width: int + tn_height: int + fullname: str | None = None + displayname: str | None = None + md5: str | None = None + nsfw: int | None = None + duration: str | None = None + duration_secs: int | None = None + pack: str | None = None + sticker: str | None = None + install: str | None = None @property def size_bytes(self) -> int: @@ -32,142 +50,19 @@ def size_string(self) -> str: @property def original_name(self) -> str: - return self.name + return self.fullname or self.displayname or self.name @property def extension(self) -> str: - if '.' not in self.name: - return '' - return self.name.lower().split('.')[-1] - - def url(self) -> str: - return f'{API_BASE}{self.path}' - - def url_thumbnail(self) -> str: - return f'{API_BASE}{self.thumbnail}' - - @staticmethod - def _download_path(path: Path = None) -> Path: - if path is None: - path = downloads_dir - path.mkdir(parents=True, exist_ok=True) - return path - - @staticmethod - async def _download_async(url: str, path: Path) -> int: - chunk_size = 2 ** 14 - - size = 0 - async with aiohttp.ClientSession() as session: - async with session.get(url) as r: - async with aiofiles.open(str(path.absolute()), 'wb') as f: - while True: - chunk = await r.content.read(chunk_size) - if not chunk: - break - size += await f.write(chunk) - return size - - async def download_async(self, path: Path = None, skip_if_exists: bool = True) -> int: - path = self._download_path(path) - if path.is_dir(): - prefix = self.name.split('.')[0] - path = path / f'{prefix}_{self.original_name}' - if skip_if_exists: - if path.exists(): - return path.stat().st_size - return await self._download_async(self.url(), path) - - async def download_thumbnail_async(self, path: Path = None, skip_if_exists: bool = True) -> int: - path = self._download_path(path) - if path.is_dir(): - filename = self.thumbnail.split('/')[-1] - path = path / filename - if skip_if_exists: - if path.exists(): - return path.stat().st_size - return await self._download_async(self.url_thumbnail(), path) - - @staticmethod - def _download(url: str, path: Path) -> int: - chunk_size = 2 ** 14 - - size = 0 - with requests.get(url, stream=True, timeout=5 * 60) as r: - with path.open('wb') as f: - for chunk in r.iter_content(chunk_size=chunk_size): - size += f.write(chunk) - return size - - def download(self, path: Path = None, skip_if_exists: bool = True) -> int: - path = self._download_path(path) - if path.is_dir(): - prefix = self.name.split('.')[0] - path = path / f'{prefix}_{self.original_name}' - if skip_if_exists: - if path.exists(): - return path.stat().st_size - return self._download(self.url(), path) - - def download_thumbnail(self, path: Path = None, skip_if_exists: bool = True) -> int: - path = self._download_path(path) - if path.is_dir(): - filename = self.thumbnail.split('/')[-1] - path = path / filename - if skip_if_exists: - if path.exists(): - return path.stat().st_size - return self._download(self.url_thumbnail(), path) - - -class Image(File): - fullname: str - displayname: str - md5: str - nsfw: bool - - @property - def original_name(self) -> str: - return self.fullname - - @validator('type') - def type_check(cls, v): - assert v in (1, 2, 4) - return v - - -class Video(File): - fullname: str - displayname: str - md5: str - nsfw: bool - duration: Optional[str] - duration_secs: Optional[int] - - @property - def original_name(self) -> str: - return self.fullname - - @property - def is_webm(self) -> bool: - return self.type == 6 - - @property - def is_mp4(self) -> bool: - return self.type == 10 + return PurePosixPath(self.name).suffix.removeprefix(".").lower() - @validator('type') - def type_check(cls, v): - assert v in (6, 10) - return v + def url(self, base_url: str = API_BASE) -> str: + return f"{base_url.rstrip('/')}/{self.path.lstrip('/')}" + def url_thumbnail(self, base_url: str = API_BASE) -> str: + return f"{base_url.rstrip('/')}/{self.thumbnail.lstrip('/')}" -class Sticker(File): - install: str - pack: str - sticker: str - @validator('type') - def type_check(cls, v): - assert v in (100,) - return v +Image = File +Video = File +Sticker = File diff --git a/api2ch/models/post.py b/api2ch/models/post.py index 14d638d..df97ef1 100644 --- a/api2ch/models/post.py +++ b/api2ch/models/post.py @@ -1,45 +1,60 @@ import datetime -from typing import List, Optional, Union + +from pydantic import Field, field_validator from api2ch.config import API_BASE from api2ch.models.auxiliary import BannedStatus from api2ch.models.base import Base -from api2ch.models.file import File, Image, Sticker, Video +from api2ch.models.file import File from api2ch.utils import clear_html, convert_html class Post(Base): - subject: str + num: int + parent: int + board: str + timestamp: int + lasthit: int + date: str + comment: str + views: int + sticky: int + endless: int + closed: int + banned: BannedStatus | int + op: int + subject: str = "" + files: list[File] = Field(default_factory=list) + email: str = "" + name: str = "" + trip: str = "" + icon: str | None = None + trip_style: str | None = None + tags: str | None = None + likes: int | None = None + dislikes: int | None = None + number: int | None = None + unique_posters: int | str | None = None + files_count: int | None = None + posts_count: int | None = None + + @field_validator("files", mode="before") + @classmethod + def empty_files(cls, value: object) -> object: + return [] if value is None else value @property - def header(self): + def header(self) -> str: return clear_html(self.subject) - comment: str - @property - def body(self): + def body(self) -> str: return convert_html(self.comment) @property - def body_text(self): + def body_text(self) -> str: return clear_html(self.comment) - files: List[Union[Image, Video, Sticker, File]] - timestamp: int - date: str - - def dt(self, tz=None) -> datetime.datetime: - return datetime.datetime.fromtimestamp(self.timestamp, tz=tz) - - lasthit: int - - def dt_last_post(self, tz=None) -> datetime.datetime: - return datetime.datetime.fromtimestamp(self.lasthit, tz=tz) - - num: int - parent: int - @property def post_id(self) -> int: return self.num @@ -48,22 +63,11 @@ def post_id(self) -> int: def parent_id(self) -> int: return self.parent or self.num - def url(self, board: str): - return f'{API_BASE}/{board}/res/{self.parent_id}.html#{self.post_id}' - - banned: BannedStatus - closed: bool - op: bool - endless: bool - sticky: int + def dt(self, tz: datetime.tzinfo | None = None) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.timestamp, tz=tz) - email: str - name: str - trip: str + def dt_last_post(self, tz: datetime.tzinfo | None = None) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.lasthit, tz=tz) - number: Optional[int] - tags: Optional[str] - trip_type: Optional[str] - unique_posters: Optional[str] - files_count: Optional[int] - posts_count: Optional[int] + def url(self, base_url: str = API_BASE) -> str: + return f"{base_url.rstrip('/')}/{self.board}/res/{self.parent_id}.html#{self.post_id}" diff --git a/api2ch/models/request.py b/api2ch/models/request.py deleted file mode 100644 index 12b676a..0000000 --- a/api2ch/models/request.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Union - -from api2ch.models.base import Request -from api2ch.models.response import ResponseBoards, ResponseBoardsByTypes, ResponseCatalog, ResponseCatalogByDate, ResponsePage, \ - ResponseSinglePost, ResponseThread, ResponseThreadPostsByNum, ResponseThreadPostsByPost, ResponseThreads - - -class RequestThread(Request): - __returning__ = ResponseThread - - board: str - thread: Union[str, int] - - def url(self, base: str) -> str: - return f'{base}/{self.board}/res/{self.thread}.json' - - -class RequestThreads(Request): - __returning__ = ResponseThreads - - board: str - - def url(self, base: str) -> str: - return f'{base}/{self.board}/threads.json' - - -class RequestCatalog(Request): - __returning__ = ResponseCatalog - - board: str - - def url(self, base: str) -> str: - return f'{base}/{self.board}/catalog.json' - - -class RequestCatalogByDate(Request): - __returning__ = ResponseCatalogByDate - - board: str - - def url(self, base: str) -> str: - return f'{base}/{self.board}/catalog_num.json' - - -class RequestPage(Request): - __returning__ = ResponsePage - - board: str - page: Union[str, int] - - def url(self, base: str) -> str: - return f'{base}/{self.board}/{self.page}.json' - - -class RequestBoards(Request): - __returning__ = ResponseBoards - - def url(self, base: str) -> str: - return f'{base}/boards.json' - - -class RequestBoardsByTypes(Request): - __returning__ = ResponseBoardsByTypes - - def url(self, base: str) -> str: - return f'{base}/makaba/mobile.fcgi?task=get_boards' - - -class RequestThreadPostsByNum(Request): - __returning__ = ResponseThreadPostsByNum - - board: str - thread: Union[str, int] - num: Union[str, int] - - def url(self, base: str) -> str: - return f'{base}/makaba/mobile.fcgi?task=get_thread&board={self.board}&thread={self.thread}&num={self.num}' - - -class RequestThreadPostsByPost(Request): - __returning__ = ResponseThreadPostsByPost - - board: str - thread: Union[str, int] - post: Union[str, int] - - def url(self, base: str) -> str: - return f'{base}/makaba/mobile.fcgi?task=get_thread&board={self.board}&thread={self.thread}&post={self.post}' - - -class RequestSinglePost(Request): - __returning__ = ResponseSinglePost - - board: str - post: Union[str, int] - - def url(self, base: str) -> str: - return f'{base}/makaba/mobile.fcgi?task=get_post&board={self.board}&post={self.post}' diff --git a/api2ch/models/response.py b/api2ch/models/response.py index e41adb8..860a974 100644 --- a/api2ch/models/response.py +++ b/api2ch/models/response.py @@ -1,124 +1,99 @@ -from typing import List, Optional +from pydantic import Field -from pydantic import BaseModel, Field, validator - -from api2ch.models.auxiliary import Tag -from api2ch.models.base import Response -from api2ch.models.board import Board, BoardInfo, BoardInfoMini +from api2ch.models.auxiliary import ThreadInfo, UpstreamError +from api2ch.models.base import Base +from api2ch.models.board import Board from api2ch.models.post import Post -from api2ch.models.thread import Thread, ThreadWithStats +from api2ch.models.thread import Thread, ThreadSummary -class ResponseThread(Response, BoardInfo): - current_thread: str +class ResponseThread(Base): + board: Board + current_thread: int files_count: int - is_board: int + is_board: bool is_closed: int - is_index: int + is_index: bool max_num: int posts_count: int thread_first_image: str - threads: List[Thread] + threads: list[Thread] title: str - unique_posters: str + unique_posters: int | str + advert_mobile_image: str | None = None + advert_mobile_link: str | None = None + board_banner_image: str | None = None + board_banner_link: str | None = None @property - def posts(self) -> List[Post]: - return self.threads[0].posts + def posts(self) -> list[Post]: + return self.threads[0].posts if self.threads else [] -class ResponseThreads(Response): +class ResponseThreads(Base): board: str - threads: List[ThreadWithStats] + threads: list[ThreadSummary] - def sorted_by_views(self, reverse: bool = True) -> List[ThreadWithStats]: - return sorted(self.threads, key=lambda t: t.views, reverse=reverse) + def sorted_by_views(self, reverse: bool = True) -> list[ThreadSummary]: + return sorted(self.threads, key=lambda thread: thread.views, reverse=reverse) - def sorted_by_posts_count(self, reverse: bool = True) -> List[ThreadWithStats]: - return sorted(self.threads, key=lambda t: t.posts_count, reverse=reverse) + def sorted_by_posts_count(self, reverse: bool = True) -> list[ThreadSummary]: + return sorted(self.threads, key=lambda thread: thread.posts_count, reverse=reverse) - def sorted_by_score(self, reverse: bool = True) -> List[ThreadWithStats]: - return sorted(self.threads, key=lambda t: t.score, reverse=reverse) + def sorted_by_score(self, reverse: bool = True) -> list[ThreadSummary]: + return sorted(self.threads, key=lambda thread: thread.score, reverse=reverse) - def sorted_by_creation(self, reverse: bool = True) -> List[ThreadWithStats]: - return sorted(self.threads, key=lambda t: t.timestamp, reverse=reverse) + def sorted_by_creation(self, reverse: bool = True) -> list[ThreadSummary]: + return sorted(self.threads, key=lambda thread: thread.timestamp, reverse=reverse) -class ResponseCatalog(Response, BoardInfo): +class ResponseCatalog(Base): + board: Board filter: str - threads: List[Thread] + threads: list[Post] + advert_mobile_image: str | None = None + advert_mobile_link: str | None = None + board_banner_image: str | None = None + board_banner_link: str | None = None -class ResponseCatalogByDate(Response, BoardInfo): - filter: str - threads: List[Thread] +ResponseCatalogByDate = ResponseCatalog -class ResponsePage(Response, BoardInfo): +class ResponsePage(Base): + board: Board board_speed: int current_page: int current_thread: int is_board: bool is_index: bool - pages: List[int] - threads: List[Thread] - - -class ResponseBoards(Response): - boards: List[Board] - global_boards: int - global_posts: str - global_speed: str - is_index: int - tags: List[Tag] - type: int - - -class ResponseBoardsByTypes(Response): - Adult: List[BoardInfoMini] = Field(alias='Взрослым') - VideoGames: List[BoardInfoMini] = Field(alias='Игры') - Politics: List[BoardInfoMini] = Field(alias='Политика') - UserBoards: List[BoardInfoMini] = Field(alias='Пользовательские') - Misc: List[BoardInfoMini] = Field(alias='Разное') - Art: List[BoardInfoMini] = Field(alias='Творчество') - Interests: List[BoardInfoMini] = Field(alias='Тематика') - Technology: List[BoardInfoMini] = Field(alias='Техника и софт') - JapaneseCulture: List[BoardInfoMini] = Field(alias='Японская культура') - - -class ResponseThreadPostsHelper(BaseModel): - __root__: List[Post] - - -class ResponseThreadPostsByNum(Response): - posts: List[Post] + pages: list[int] + threads: list[Thread] + advert_mobile_image: str | None = None + advert_mobile_link: str | None = None + board_banner_image: str | None = None + board_banner_link: str | None = None - @classmethod - def parse_obj(cls, *args, **kwargs): - h = ResponseThreadPostsHelper.parse_obj(*args, **kwargs) - return cls(posts=h.__root__) +class ResponseThreadInfo(Base): + result: int + thread: ThreadInfo | None = None + error: UpstreamError | None = None -class ResponseThreadPostsByPost(Response): - posts: List[Post] - @classmethod - def parse_obj(cls, *args, **kwargs): - h = ResponseThreadPostsHelper.parse_obj(*args, **kwargs) - return cls(posts=h.__root__) +class ResponseThreadPostsByNum(Base): + result: int + unique_posters: int | None = None + posts: list[Post] = Field(default_factory=list) + error: UpstreamError | None = None -class ResponseSinglePost(Response): - post: Optional[Post] +class ResponseSinglePost(Base): + result: int + post: Post | None = None + error: UpstreamError | None = None - @classmethod - def parse_obj(cls, *args, **kwargs): - h = ResponseThreadPostsHelper.parse_obj(*args, **kwargs) - return cls(posts=h.__root__) - @validator('post', pre=True) - def parse(cls, v): - if isinstance(v, list): - if v: - return v[0] - return None +ResponseBoards = list[Board] +ResponseBoardsByTypes = dict[str, list[Board]] +ResponseThreadPostsByPost = ResponseThreadPostsByNum diff --git a/api2ch/models/thread.py b/api2ch/models/thread.py index 51c05f0..f54b624 100644 --- a/api2ch/models/thread.py +++ b/api2ch/models/thread.py @@ -1,5 +1,6 @@ import datetime -from typing import List, Optional + +from pydantic import Field from api2ch.config import API_BASE from api2ch.models.base import Base @@ -8,45 +9,46 @@ class Thread(Base): - posts: Optional[List[Post]] + posts: list[Post] = Field(default_factory=list) + thread_num: int | None = None + files_count: int | None = None + posts_count: int | None = None + +class ThreadSummary(Base): + num: int + timestamp: int + lasthit: int + views: int + posts_count: int + score: float + subject: str = "" + comment: str = "" -class ThreadWithStats(Thread): - subject: str + @property + def thread_id(self) -> int: + return self.num @property - def header(self): + def header(self) -> str: return clear_html(self.subject) - comment: str - @property - def body(self): + def body(self) -> str: return convert_html(self.comment) @property - def body_text(self): + def body_text(self) -> str: return clear_html(self.comment) - num: int - - @property - def thread_id(self) -> int: - return self.num - - def url(self, board: str) -> str: - return f'{API_BASE}/{board}/res/{self.thread_id}.html' - - timestamp: int - - def dt(self, tz=None) -> datetime.datetime: + def dt(self, tz: datetime.tzinfo | None = None) -> datetime.datetime: return datetime.datetime.fromtimestamp(self.timestamp, tz=tz) - lasthit: int - - def dt_last_post(self, tz=None) -> datetime.datetime: + def dt_last_post(self, tz: datetime.tzinfo | None = None) -> datetime.datetime: return datetime.datetime.fromtimestamp(self.lasthit, tz=tz) - views: int - posts_count: int - score: float + def url(self, board: str, base_url: str = API_BASE) -> str: + return f"{base_url.rstrip('/')}/{board}/res/{self.thread_id}.html" + + +ThreadWithStats = ThreadSummary diff --git a/api2ch/py.typed b/api2ch/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api2ch/py.typed @@ -0,0 +1 @@ + diff --git a/api2ch/utils.py b/api2ch/utils.py index 9e4b7e1..605a9ed 100644 --- a/api2ch/utils.py +++ b/api2ch/utils.py @@ -1,64 +1,59 @@ +import html import re -from typing import Tuple -from urllib.parse import ParseResult, urlparse +from html.parser import HTMLParser +from urllib.parse import urlparse -from api2ch.config import BOARDS, hostname_mirrors +from api2ch.config import hostname_mirrors -def prettify_bytes(size: float) -> str: - for unit in ('Б', 'Кб', 'Мб', 'Гб', 'Тб'): - if size < 1024.0: - break - size /= 1024.0 - return f'{size:.0f} {unit}' if unit in ('Б', 'Кб') else f'{size:.2f} {unit}' +class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "br": + self.parts.append("\n") -def parse_url(url: str) -> Tuple[bool, str, int]: - """ - Parse url with checks - :param url: example: 'https://2ch.hk/api/res/1.html' - :return: is_valid, board, thread_id - """ - result: ParseResult = urlparse(url) - bad = False, '', 0 + def handle_data(self, data: str) -> None: + self.parts.append(data) - if result.hostname not in hostname_mirrors: - return bad - split = re.split('[/.]', result.path) - if len(split) < 3: - return bad +def prettify_bytes(size: float) -> str: + units = ("B", "KiB", "MiB", "GiB", "TiB") + index = 0 + while size >= 1024.0 and index < len(units) - 1: + size /= 1024.0 + index += 1 + unit = units[index] + return f"{size:.0f} {unit}" if unit in {"B", "KiB"} else f"{size:.2f} {unit}" - board, method, thread = split[1], split[2], split[3] - if board not in BOARDS or method != 'res' or not thread.isdigit(): - return bad - thread_id = int(thread) - return True, board, thread_id +def parse_url(url: str) -> tuple[bool, str, int]: + """Parse a 2ch thread URL without relying on a frozen board list.""" + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or parsed.hostname not in hostname_mirrors: + return False, "", 0 -def convert_html(text: str) -> str: - """Telegram acceptable HTML code""" - text = re.sub(r'
', '\n', text) - text = re.sub(r' ', ' ', text) - text = re.sub(r'"', '\'', text) - text = re.sub(r'/', '/', text) + match = re.fullmatch(r"/([a-z0-9_]+)/res/([1-9][0-9]*)\.html/?", parsed.path) + if match is None: + return False, "", 0 + return True, match.group(1), int(match.group(2)) - text = re.sub(r'<(/?)strong>', r'<\1b>', text) - text = re.sub(r'<(/?)em>', r'<\1i>', text) - text = re.sub(r'', '', text) - text = re.sub(r'', '', text) - text = re.sub(r'', '', text) - return text +def clear_html(text: str) -> str: + parser = _TextExtractor() + parser.feed(text) + parser.close() + return "".join(parser.parts) -def clear_html(text: str) -> str: - """Clear text from HTML tags""" - text = re.sub(r'
', '\n', text) - text = re.sub(r' ', ' ', text) - text = re.sub(r'"', '\'', text) - text = re.sub(r'/', '/', text) - - text = re.sub(r'<.*?>', '', text) - return text +def convert_html(text: str) -> str: + """Convert the small upstream markup subset to conservative rich text.""" + + converted = re.sub(r"", "\n", text, flags=re.IGNORECASE) + converted = re.sub(r"<(\/?)strong>", r"<\1b>", converted, flags=re.IGNORECASE) + converted = re.sub(r"<(\/?)em>", r"<\1i>", converted, flags=re.IGNORECASE) + converted = re.sub(r"]*)?>", "", converted, flags=re.IGNORECASE) + return html.unescape(converted) diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..84f97e4 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,34 @@ +# Verified upstream contract + +Verified on 16 September 2026 against `https://2ch.su` with read-only requests. + +The official [API guide](https://2ch.su/abu/res/42375.html) links [OpenAPI 1.0.31](https://2ch.su/api.yml). The retrieved document had SHA-256 `9d8ced595f2d089e29d61a975677b263f4b0c93307b82c599b9a373382426e55` and a server `Last-Modified` value of 31 August 2024. + +## Endpoint matrix + +| Capability | Route | Observed result | 2.0 decision | +|---|---|---|---| +| Boards | `/api/mobile/v2/boards` | JSON array of board objects | Use | +| Thread summaries | `/{board}/threads.json` | Object with `board` and `threads` | Keep | +| Catalog, activity order | `/{board}/catalog.json` | Object with nested `board` and OP posts | Keep | +| Catalog, creation order | `/{board}/catalog_num.json` | Same envelope as catalog | Keep | +| Board page | `/{board}/{page}.json` | Nested `board`; threads contain posts | Keep | +| Complete thread | `/{board}/res/{thread}.json` | Nested `board`; one thread containing posts | Keep | +| Thread information | `/api/mobile/v2/info/{board}/{thread}` | `result` plus `thread` | Add | +| Posts at or after a board post number | `/api/mobile/v2/after/{board}/{thread}/{num}` | `result`, `unique_posters`, `posts` | Replaces legacy `get_thread&num=` | +| Single post | `/api/mobile/v2/post/{board}/{num}` | `result` plus `post` | Replaces legacy `get_post` | +| Legacy boards | `/boards.json` | HTTP 404 | Remove | +| Legacy mobile reads | `/makaba/mobile.fcgi?...` | HTTP 404 | Remove | +| Posts after a thread ordinal | Legacy `get_thread&post=` | No documented v2 equivalent | Unsupported | + +The v2 mobile API reports missing boards, threads and posts as an HTTP 200 JSON error envelope with `result: 0` and an `error` object. The client converts that envelope to `Api2chUpstreamError`. + +Observed compatibility details covered by fixtures and live tests: + +- board metadata is a nested object in catalog, page and thread responses; +- page post entries may contain `files: null`, normalized to an empty list; +- incidental presentation fields may be absent; +- unknown response fields and unknown numeric attachment types are preserved; +- mobile boards are a flat array, so categories are grouped dynamically client-side. + +Fixtures in `tests/fixtures/current_api.json` contain synthetic text and identifiers, not copied user media or post content. `tests/test_integration.py` discovers a current thread at runtime instead of depending on a historical thread remaining available. diff --git a/docs/migration-2.md b/docs/migration-2.md new file mode 100644 index 0000000..3387db5 --- /dev/null +++ b/docs/migration-2.md @@ -0,0 +1,32 @@ +# Migrating from api2ch 1.x + +api2ch 2.0 is a deliberate breaking release for Python 3.14 and the current upstream API. + +## Runtime and lifecycle + +- Python 3.14 or newer is required. +- Requests, aiohttp and aiofiles were replaced by HTTPX and AnyIO. +- Use `Api2ch` or `Api2chAsync` as a context manager. Injected HTTPX clients remain caller-owned. +- `raw_results=True` was removed. Use `api.raw.(...)` explicitly. + +## Endpoint changes + +- `boards()` now reads `/api/mobile/v2/boards` and returns `list[Board]`. +- `boards_by_types()` returns a dynamic dictionary keyed by the upstream category text. +- `thread_info()`, `posts_after()` and `post()` expose the current mobile v2 API. +- `thread_posts_by_num` aliases `posts_after`; `single_post` aliases `post`. +- `thread_posts_by_post` was removed because the ordinal-based legacy operation has no documented v2 equivalent. +- API errors inside HTTP 200 responses now raise `Api2chUpstreamError`. + +## Models and exports + +- Models use native Pydantic 2 APIs such as `model_validate()` and `model_dump()`. +- Requests no longer execute themselves and responses no longer retain a live request/client reference. +- `Request*`, `Response`, `NewsAbu`, `Top` and `Tag` are no longer public API. +- Board metadata is available through `response.board`, for example `response.board.name`. +- Board categories are not translated into fixed attributes. +- Model methods no longer download data. Use `download_file()` or `download_file_async()`. + +## Downloads + +Downloads check HTTP status, stream to a temporary file and atomically replace the destination after success. They return `DownloadResult` with the destination, transferred byte count and skip status. A destination with a suffix is treated as a filename; an existing directory or suffixless new path is treated as a directory. diff --git a/examples/boards.py b/examples/boards.py deleted file mode 100644 index 80ce0fa..0000000 --- a/examples/boards.py +++ /dev/null @@ -1,7 +0,0 @@ -from api2ch import Api2ch - -api = Api2ch() - -for board in ('tv', 'hw', 'fiz'): - c = api.catalog(board) - print(f'/{c.board} | {c.board_name}, "{c.board_info_outer}", bump limit: {c.bump_limit}') diff --git a/examples/boards_async.py b/examples/boards_async.py deleted file mode 100644 index 6d649c9..0000000 --- a/examples/boards_async.py +++ /dev/null @@ -1,15 +0,0 @@ -import asyncio - -from api2ch import Api2chAsync - - -async def main(): - async with Api2chAsync() as api: - coros = [api.catalog(board) for board in ('spc', 'un', 'math')] - - for coro in asyncio.as_completed(coros): - c = await coro - print(f'/{c.board} | {c.board_name}, "{c.board_info_outer}", bump limit: {c.bump_limit}') - - -asyncio.run(main()) diff --git a/examples/complex.py b/examples/complex.py deleted file mode 100644 index a62c89d..0000000 --- a/examples/complex.py +++ /dev/null @@ -1,37 +0,0 @@ -import api2ch - -api = api2ch.Api2ch() - - -def parse_post(url: str) -> str: - valid, board, thread_id = api2ch.parse_url(url) - if not valid: - raise api2ch.Api2chError(404, 'Invalid URL') - - thread = api.thread(board, thread_id) - post = thread.posts[0] - text = '' - - text += f'{post.dt().isoformat()} | Пост №{post.post_id}: {post.url(thread.board)}:\n\n' - text += f'{post.header}\n' if thread.enable_subject else '' - text += f'{post.body_text}\n\n' - - if post.files: - text += 'Файлы:\n' - for f in post.files: - text += f'— {f.original_name}, {f.size_string}: {f.url()}\n' - - return text - - -def pretty_print_post(url: str): - try: - text = parse_post(url) - except api2ch.Api2chError as e: - print('Request Error', e.code, e.reason) - else: - print(text) - - -if __name__ == '__main__': - pretty_print_post('https://2ch.hk/cg/res/1323206.html') diff --git a/examples/complex_async.py b/examples/complex_async.py deleted file mode 100644 index c453a35..0000000 --- a/examples/complex_async.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -import api2ch - -api = api2ch.Api2chAsync() - - -async def parse_post(url: str) -> str: - valid, board, thread_id = api2ch.parse_url(url) - if not valid: - raise api2ch.Api2chError(404, 'Invalid URL') - - thread = await api.thread(board, thread_id) - post = thread.posts[0] - text = '' - - text += f'{post.dt().isoformat()} | Пост №{post.post_id}: {post.url(thread.board)}:\n\n' - text += f'{post.header}\n' if thread.enable_subject else '' - text += f'{post.body_text}\n\n' - - if post.files: - text += 'Файлы:\n' - for f in post.files: - text += f'— {f.original_name}, {f.size_string}: {f.url()}\n' - - return text - - -async def pretty_print_post(url: str): - try: - text = await parse_post(url) - except api2ch.Api2chError as e: - print('Request Error', e.code, e.reason) - else: - print(text) - - -if __name__ == '__main__': - asyncio.run(pretty_print_post('https://2ch.hk/cg/res/1323206.html')) diff --git a/examples/simple.py b/examples/simple.py index 7a418d8..7de9780 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,7 +1,5 @@ from api2ch import Api2ch -api = Api2ch() - -resp = api.threads('vg') -for t in resp.threads[:3]: - print(f'— {t.subject}, {t.posts_count} 💬, {t.views} 👁') +with Api2ch() as api: + for thread in api.threads("pr").sorted_by_views()[:3]: + print(thread.num, thread.header, thread.views) diff --git a/examples/simple_async.py b/examples/simple_async.py index 7db7fe3..ab3e9d5 100644 --- a/examples/simple_async.py +++ b/examples/simple_async.py @@ -3,11 +3,11 @@ from api2ch import Api2chAsync -async def main(): +async def main() -> None: async with Api2chAsync() as api: - resp = await api.threads('hw') - for t in resp.threads[:3]: - print(f'— {t.subject}, {t.posts_count} 💬, {t.views} 👁') + threads = await api.threads("hw") + for thread in threads.sorted_by_views()[:3]: + print(thread.num, thread.header, thread.views) asyncio.run(main()) diff --git a/examples/top.py b/examples/top.py deleted file mode 100644 index 2e7c90d..0000000 --- a/examples/top.py +++ /dev/null @@ -1,9 +0,0 @@ -from api2ch import Api2ch - -api = Api2ch() - -boards = api.boards_by_types() -for board in boards.Art: - threads = api.threads(board.id) - top_thread = threads.sorted_by_views()[0] - print(f'— /{threads.request.board} | {board.name} | Top thread: {top_thread.subject}, {top_thread.views} 👁') diff --git a/examples/top_async.py b/examples/top_async.py deleted file mode 100644 index e187170..0000000 --- a/examples/top_async.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio - -from api2ch import Api2chAsync - - -async def main(): - async with Api2chAsync() as api: - boards = await api.boards_by_types() - - coros = [api.threads(board.id) for board in boards.Technology] - - for coro in asyncio.as_completed(coros): - threads = await coro - top_thread = threads.sorted_by_views()[0] - print(f'— /{threads.request.board} | Top thread: {top_thread.subject}, {top_thread.views} 👁') - - -asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a7985f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["hatchling==1.32.0"] +build-backend = "hatchling.build" + +[project] +name = "api2ch" +version = "2.0.0" +description = "Typed synchronous and asynchronous clients for the read-only 2ch API" +readme = "readme.md" +requires-python = ">=3.14" +license = "MIT" +authors = [{ name = "uburuntu", email = "github@rmbk.me" }] +keywords = ["2ch", "dvach", "api", "asyncio"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "anyio>=4.15.1,<5", + "httpx>=0.28.1,<0.29", + "pydantic>=2.13.5,<3", + "typing-extensions>=4.15,<5", +] + +[project.optional-dependencies] +dev = [ + "build>=1.6,<2", + "mypy>=2.3.1,<3", + "pytest>=9.1.1,<10", + "pytest-asyncio>=1.4,<2", + "pytest-cov>=7,<8", + "ruff>=0.16.8,<0.17", + "twine>=7,<8", +] + +[project.urls] +Homepage = "https://github.com/uburuntu/api2ch" +Issues = "https://github.com/uburuntu/api2ch/issues" + +[tool.hatch.build.targets.wheel] +packages = ["api2ch"] + +[tool.hatch.build.targets.sdist] +include = ["api2ch", "tests", "LICENSE", "readme.md", "RELEASING.md", "docs"] + +[tool.pytest.ini_options] +addopts = "-ra --strict-markers" +asyncio_mode = "strict" +markers = ["integration: calls the live 2ch API"] + +[tool.ruff] +line-length = 100 +target-version = "py314" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "RUF"] + +[tool.mypy] +python_version = "3.14" +strict = true +packages = ["api2ch"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false diff --git a/readme.md b/readme.md index aa8ea56..64fc528 100644 --- a/readme.md +++ b/readme.md @@ -1,324 +1,114 @@ # api2ch -[![Python](https://img.shields.io/badge/Python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue.svg?longCache=true)]() -[![PyPI](https://img.shields.io/pypi/v/api2ch.svg)](https://pypi.python.org/pypi/api2ch) +[![Python](https://img.shields.io/badge/Python-3.14-blue.svg)](https://www.python.org/) +[![PyPI](https://img.shields.io/pypi/v/api2ch.svg)](https://pypi.org/project/api2ch/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Python Tests](https://github.com/uburuntu/api2ch/actions/workflows/tests.yml/badge.svg)](https://github.com/uburuntu/api2ch/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/uburuntu/api2ch/branch/master/graph/badge.svg)](https://codecov.io/gh/uburuntu/api2ch) -⚡️ Async API Wrapper for 2ch Imageboard with typings +⚡ Typed synchronous and asynchronous clients for the read-only 2ch API. -Docs: https://2ch.hk/api/res/1.html - -![](https://i.imgur.com/VLbQhEv.jpg) - -## 📝 Table of Contents - -- [🎒 Installation](#-installation) -- [🛠 Examples](#-examples) - - [Simple](#simple) - - [Simple Async](#simple-async) - - [Boards](#boards) - - [Boards Async](#boards-async) - - [Top threads](#top-threads) - - [Top Threads Async](#top-threads-async) - - [Complex](#complex) - - [Complex Async](#complex-async) -- [📜 Manual](#-manual) - - [Methods](#methods) - - [Types](#types) - - [In case of unsupported types](#in-case-of-unsupported-types) -- [💬 Contributing](#-contributing) -- [📝 License](#-license) - - ---- +Version 2 uses the current `/api/mobile/v2` endpoints, HTTPX and native Pydantic 2 models. It requires Python 3.14 or newer. ## 🎒 Installation -Just - -``` +```console pip install api2ch ``` ## 🛠 Examples -### Simple +### Synchronous -```python3 +```python from api2ch import Api2ch -api = Api2ch() - -resp = api.threads('vg') -for t in resp.threads[:3]: - print(f'— {t.subject}, {t.posts_count} 💬, {t.views} 👁') -``` - -Output: +with Api2ch() as api: + boards = api.boards() + threads = api.threads("pr") -```text -— Paradox Thread №6 стрессовый, 771 💬, 1879 👁 -— BioWare General: Varric Tethras Edition, 207 💬, 644 👁 -— Fate/Grand Order #193, 683 💬, 4804 👁 + for thread in threads.sorted_by_views()[:3]: + print(thread.num, thread.header, thread.views) ``` -### Simple Async +### Asynchronous -```python3 +```python import asyncio from api2ch import Api2chAsync -async def main(): +async def main() -> None: async with Api2chAsync() as api: - resp = await api.threads('hw') - for t in resp.threads[:3]: - print(f'— {t.subject}, {t.posts_count} 💬, {t.views} 👁') + threads = await api.threads("hw") + thread = await api.thread("hw", threads.threads[0].num) + print(thread.posts[0].body_text) asyncio.run(main()) ``` -Output: +### Current mobile API -```text -— Видеокарты AMD #95, 418 💬, 3778 👁 -— Сап. Впервые в этом разделе. Как научится разбираться в железе?, 3 💬, 9 👁 -— Ноутбукотред №36, 185 💬, 970 👁 +```python +with Api2ch() as api: + thread_id = api.threads("pr").threads[0].num + info = api.thread_info("pr", thread_id) + new_posts = api.posts_after("pr", thread_id, thread_id) + opening_post = api.post("pr", thread_id) ``` -### Boards +`boards_by_types()` now returns a dynamic `dict[str, list[Board]]`; categories are no longer fixed translated attributes. -```python3 -from api2ch import Api2ch +### Raw JSON -api = Api2ch() +Typed calls never fall back to unvalidated data. Use the explicit raw namespace when the original JSON is needed: -for board in ('tv', 'hw', 'fiz'): - c = api.catalog(board) - print(f'/{c.board} | {c.board_name}, "{c.board_info_outer}", bump limit: {c.bump_limit}') +```python +with Api2ch() as api: + payload = api.raw.catalog("pr") ``` -Output: - -```text -/tv | Сериалы, "сериалы для домохозяек, игры престолов в /got/", bump limit: 500 -/hw | Компьютерное железо, "железо, видеокарты, ноутбуки, intel, amd, nvidia, ati", bump limit: 500 -/fiz | Физкультура, "физкультура, здоровье, сбросить вес, набрать вес, бицуха", bump limit: 800 -``` - -### Boards Async - -```python3 -import asyncio - -from api2ch import Api2chAsync +The raw namespace still applies timeout, transport, HTTP, JSON and upstream error handling. +## 📜 Methods -async def main(): - async with Api2chAsync() as api: - coros = [api.catalog(board) for board in ('spc', 'un', 'math')] - - for coro in asyncio.as_completed(coros): - c = await coro - print(f'/{c.board} | {c.board_name}, "{c.board_info_outer}", bump limit: {c.bump_limit}') +| Method | Current route | +|---|---| +| `boards()` | `/api/mobile/v2/boards` | +| `threads(board)` | `/{board}/threads.json` | +| `catalog(board)` | `/{board}/catalog.json` | +| `catalog_by_date(board)` | `/{board}/catalog_num.json` | +| `page(board, page)` | `/{board}/{page}.json` | +| `thread(board, thread)` | `/{board}/res/{thread}.json` | +| `thread_info(board, thread)` | `/api/mobile/v2/info/{board}/{thread}` | +| `posts_after(board, thread, num)` | `/api/mobile/v2/after/{board}/{thread}/{num}` | +| `post(board, num)` | `/api/mobile/v2/post/{board}/{num}` | +`thread_posts_by_num` and `single_post` remain as unambiguous compatibility aliases. The removed ordinal-based `thread_posts_by_post` endpoint has no current upstream equivalent. -asyncio.run(main()) -``` - -Output: - -```text -/un | Образование, "образование, вуз, школа, поступление, гиа, егэ, уже не школьник", bump limit: 500 -/spc | Космос и астрономия, "космос, астрономия, вселенные, звезды, огурцы", bump limit: 500 -/math | Математика, "Доска о модулях над кольцами, пучках на многообразиях и гомологиях с когомологиями.", bump limit: 500 -``` - -### Top threads - -```python3 -from api2ch import Api2ch - -api = Api2ch() - -boards = api.boards_by_types() -for board in boards.Art: - threads = api.threads(board.id) - top_thread = threads.sorted_by_views()[0] - print(f'— /{threads.request.board} | {board.name} | Top thread: {top_thread.subject}, {top_thread.views} 👁') -``` - -Output: - -```text -— /di | Столовая | Top thread: НОВОЙ БАНОЧКИ НИТЬ ИДИ, 123475 👁 -— /de | Дизайн | Top thread: Зарплата, 48958 👁 -— /diy | Хобби | Top thread: Кристаллический тред, 1620541 👁 -— /mus | Музыканты | Top thread: Язычковых тред., 153772 👁 -— /pa | Живопись | Top thread: Сталин 3000, 40392 👁 -— /p | Фотография | Top thread: Ссылкотред, 34182 👁 -— /wp | Обои и высокое разрешение | Top thread: Милитари пак, 38348 👁 -— /wrk | Работа и карьера | Top thread: Яндекс Дзена /zen тред 11, 33648 👁 -``` - -### Top Threads Async - -```python3 -import asyncio - -from api2ch import Api2chAsync - - -async def main(): - async with Api2chAsync() as api: - boards = await api.boards_by_types() - - coros = [api.threads(board.id) for board in boards.Technology] - - for coro in asyncio.as_completed(coros): - threads = await coro - top_thread = threads.sorted_by_views()[0] - print(f'— /{threads.request.board} | Top thread: {top_thread.subject}, {top_thread.views} 👁') - - -asyncio.run(main()) -``` - -Output: - -```text -— /ra | Top thread: OsmocomBB - Motorola, 1517590 👁 -— /hw | Top thread: VR тред возрожденный #4, 17638 👁 -— /t | Top thread: Выбираем робот-пылесос, 22336 👁 -— /s | Top thread: Форки лиса, 49613 👁 -— /pr | Top thread: Советов ньюфагу тред, 25838 👁 -— /gd | Top thread: В этом треде ищем напарников для создания своих, 38633 👁 -— /mobi | Top thread: PUBG MOBILE/Пупок мобайл-THREAD, 70643 👁 -``` - -### Complex - -```python3 -import api2ch - -api = api2ch.Api2ch() - - -def parse_post(url: str) -> str: - valid, board, thread_id = api2ch.parse_url(url) - if not valid: - raise api2ch.Api2chError(404, 'Invalid URL') - - thread = api.thread(board, thread_id) - post = thread.posts[0] - text = '' - - text += f'{post.dt().isoformat()} | Пост №{post.post_id}: {post.url(thread.board)}:\n\n' - text += f'{post.header}\n' if thread.enable_subject else '' - text += f'{post.body_text}\n\n' - - if post.files: - text += 'Файлы:\n' - for f in post.files: - text += f'— {f.original_name}, {f.size_string}: {f.url()}\n' - - return text - - -def pretty_print_post(url: str): - try: - text = parse_post(url) - except api2ch.Api2chError as e: - print('Request Error', e.code, e.reason) - else: - print(text) - - -if __name__ == '__main__': - pretty_print_post('https://2ch.hk/cg/res/1323206.html') -``` - -Output: - -```text -2018-07-19T10:13:24 | Пост №1323206: https://2ch.hk/cg/res/1323206.html#1323206: - -Тред для междоусобных холиваров -Сравниваем платформы, а так же помогаем ньюфагам определиться с выбором приставки и техническими вопросами. - -Обязателен к прочтению FAQ раздела: https://2ch.hk/faq/faq_cg.html - -Файлы: -— изображение.png, 84 Кб: https://2ch.hk/cg/src/1323206/15319844042830.png -``` - -### Complex Async - -[complex_async.py](examples/complex_async.py), same as previous but: - -- `api = api2ch.Api2chAsync()` -- and `thread = await api.thread(board, thread_id)` - -## 📜 Manual - -### Methods - -`Api2ch` methods (and same for `Api2chAsync` but with `async`): - -```python3 -class Api2ch(Api2chBase): - ... - def thread(self, board: str, thread: Union[str, int]) -> ResponseThread: - ... - def threads(self, board: str) -> ResponseThreads: - ... - def catalog(self, board: str) -> ResponseCatalog: - ... - def catalog_by_date(self, board: str) -> ResponseCatalogByDate: - ... - def page(self, board: str, page: Union[str, int]) -> ResponsePage: - ... - def boards(self) -> ResponseBoards: - ... - def boards_by_types(self) -> ResponseBoardsByTypes: - ... - def thread_posts_by_num(self, board: str, thread: Union[str, int], num: Union[str, int]) -> ResponseThreadPostsByNum: - ... - def thread_posts_by_post(self, board: str, thread: Union[str, int], post: Union[str, int]) -> ResponseThreadPostsByPost: - ... - def single_post(self, board: str, post: Union[str, int]) -> ResponseSinglePost: - ... -``` - -Also available method `download_thread_media` (default path: `./dowloads_2ch/%thread_id%/`): - -```python3 -from api2ch import download_thread_media - -download_thread_media(url='https://2ch.hk/api/res/1.html', with_thumbnails=True, skip_if_exists=True) -``` - -### Types +## ⚠ Errors and ownership -This library uses [pydantic](https://github.com/samuelcolvin/pydantic/) for parsing API responses. You can see data models in [api2ch/models](api2ch/models). +`Api2chError` has distinct subclasses for timeouts, transport failures, HTTP status failures, upstream error envelopes, malformed JSON and response validation. Error objects retain the URL and, where applicable, an HTTP or upstream error code. -### In case of unsupported types +Clients created by api2ch are closed by their context manager. An injected `httpx.Client` or `httpx.AsyncClient` remains owned by the caller. -API results can change and the library may not parse the new result. So you can request «raw» dicts: +## 🧪 Development -```python3 -api = Api2ch(raw_results=True) +```console +uv sync --locked --extra dev +uv run ruff format --check api2ch tests +uv run ruff check api2ch tests +uv run mypy api2ch +uv run pytest -m "not integration" ``` -## 💬 Contributing +The ordinary suite is deterministic and offline. Maintainers can run the small read-only live contract suite with `uv run pytest tests/test_integration.py -v`, or through the manual GitHub Actions input. -Contributions, issues and feature requests are welcome! +See the [verified upstream contract](docs/api-contract.md), [2.0 migration guide](docs/migration-2.md), and [release procedure](RELEASING.md). ## 📝 License -This project is [MIT](LICENSE) licensed. +[MIT](LICENSE) diff --git a/readme.template.md b/readme.template.md deleted file mode 100644 index df0db09..0000000 --- a/readme.template.md +++ /dev/null @@ -1,207 +0,0 @@ -# api2ch - -[![Python](https://img.shields.io/badge/Python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue.svg?longCache=true)]() -[![PyPI](https://img.shields.io/pypi/v/api2ch.svg)](https://pypi.python.org/pypi/api2ch) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) - -[![Python Tests](https://github.com/uburuntu/api2ch/actions/workflows/tests.yml/badge.svg)](https://github.com/uburuntu/api2ch/actions/workflows/tests.yml) -[![codecov](https://codecov.io/gh/uburuntu/api2ch/branch/master/graph/badge.svg)](https://codecov.io/gh/uburuntu/api2ch) - -⚡️ Async API Wrapper for 2ch Imageboard with typings - -Docs: https://2ch.hk/api/res/1.html - -![](https://i.imgur.com/VLbQhEv.jpg) - -{toc} - ---- - -## 🎒 Installation - -Just - -``` -pip install api2ch -``` - -## 🛠 Examples - -### Simple - -```python3 -{simple} -``` - -Output: - -```text -— Paradox Thread №6 стрессовый, 771 💬, 1879 👁 -— BioWare General: Varric Tethras Edition, 207 💬, 644 👁 -— Fate/Grand Order #193, 683 💬, 4804 👁 -``` - -### Simple Async - -```python3 -{simple_async} -``` - -Output: - -```text -— Видеокарты AMD #95, 418 💬, 3778 👁 -— Сап. Впервые в этом разделе. Как научится разбираться в железе?, 3 💬, 9 👁 -— Ноутбукотред №36, 185 💬, 970 👁 -``` - -### Boards - -```python3 -{boards} -``` - -Output: - -```text -/tv | Сериалы, "сериалы для домохозяек, игры престолов в /got/", bump limit: 500 -/hw | Компьютерное железо, "железо, видеокарты, ноутбуки, intel, amd, nvidia, ati", bump limit: 500 -/fiz | Физкультура, "физкультура, здоровье, сбросить вес, набрать вес, бицуха", bump limit: 800 -``` - -### Boards Async - -```python3 -{boards_async} -``` - -Output: - -```text -/un | Образование, "образование, вуз, школа, поступление, гиа, егэ, уже не школьник", bump limit: 500 -/spc | Космос и астрономия, "космос, астрономия, вселенные, звезды, огурцы", bump limit: 500 -/math | Математика, "Доска о модулях над кольцами, пучках на многообразиях и гомологиях с когомологиями.", bump limit: 500 -``` - -### Top threads - -```python3 -{top} -``` - -Output: - -```text -— /di | Столовая | Top thread: НОВОЙ БАНОЧКИ НИТЬ ИДИ, 123475 👁 -— /de | Дизайн | Top thread: Зарплата, 48958 👁 -— /diy | Хобби | Top thread: Кристаллический тред, 1620541 👁 -— /mus | Музыканты | Top thread: Язычковых тред., 153772 👁 -— /pa | Живопись | Top thread: Сталин 3000, 40392 👁 -— /p | Фотография | Top thread: Ссылкотред, 34182 👁 -— /wp | Обои и высокое разрешение | Top thread: Милитари пак, 38348 👁 -— /wrk | Работа и карьера | Top thread: Яндекс Дзена /zen тред 11, 33648 👁 -``` - -### Top Threads Async - -```python3 -{top_async} -``` - -Output: - -```text -— /ra | Top thread: OsmocomBB - Motorola, 1517590 👁 -— /hw | Top thread: VR тред возрожденный #4, 17638 👁 -— /t | Top thread: Выбираем робот-пылесос, 22336 👁 -— /s | Top thread: Форки лиса, 49613 👁 -— /pr | Top thread: Советов ньюфагу тред, 25838 👁 -— /gd | Top thread: В этом треде ищем напарников для создания своих, 38633 👁 -— /mobi | Top thread: PUBG MOBILE/Пупок мобайл-THREAD, 70643 👁 -``` - -### Complex - -```python3 -{complex} -``` - -Output: - -```text -2018-07-19T10:13:24 | Пост №1323206: https://2ch.hk/cg/res/1323206.html#1323206: - -Тред для междоусобных холиваров -Сравниваем платформы, а так же помогаем ньюфагам определиться с выбором приставки и техническими вопросами. - -Обязателен к прочтению FAQ раздела: https://2ch.hk/faq/faq_cg.html - -Файлы: -— изображение.png, 84 Кб: https://2ch.hk/cg/src/1323206/15319844042830.png -``` - -### Complex Async - -[complex_async.py](examples/complex_async.py), same as previous but: - -- `api = api2ch.Api2chAsync()` -- and `thread = await api.thread(board, thread_id)` - -## 📜 Manual - -### Methods - -`Api2ch` methods (and same for `Api2chAsync` but with `async`): - -```python3 -class Api2ch(Api2chBase): - ... - def thread(self, board: str, thread: Union[str, int]) -> ResponseThread: - ... - def threads(self, board: str) -> ResponseThreads: - ... - def catalog(self, board: str) -> ResponseCatalog: - ... - def catalog_by_date(self, board: str) -> ResponseCatalogByDate: - ... - def page(self, board: str, page: Union[str, int]) -> ResponsePage: - ... - def boards(self) -> ResponseBoards: - ... - def boards_by_types(self) -> ResponseBoardsByTypes: - ... - def thread_posts_by_num(self, board: str, thread: Union[str, int], num: Union[str, int]) -> ResponseThreadPostsByNum: - ... - def thread_posts_by_post(self, board: str, thread: Union[str, int], post: Union[str, int]) -> ResponseThreadPostsByPost: - ... - def single_post(self, board: str, post: Union[str, int]) -> ResponseSinglePost: - ... -``` - -Also available method `download_thread_media` (default path: `./dowloads_2ch/%thread_id%/`): - -```python3 -from api2ch import download_thread_media - -download_thread_media(url='https://2ch.hk/api/res/1.html', with_thumbnails=True, skip_if_exists=True) -``` - -### Types - -This library uses [pydantic](https://github.com/samuelcolvin/pydantic/) for parsing API responses. You can see data models in [api2ch/models](api2ch/models). - -### In case of unsupported types - -API results can change and the library may not parse the new result. So you can request «raw» dicts: - -```python3 -api = Api2ch(raw_results=True) -``` - -## 💬 Contributing - -Contributions, issues and feature requests are welcome! - -## 📝 License - -This project is [MIT](LICENSE) licensed. diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d9589ff..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,7 +0,0 @@ -datamodel_code_generator>=0.12 -codecov>=2.0 -flake8>=4.0 -pytest>=5.4 -pytest-asyncio>=0.12 -pytest-cov>=2.8 -transliterate>=1.10 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4d648c8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -aiofiles>=0.5 -aiohttp>=3.6 -pydantic>=1.6 -requests>=2.24 diff --git a/scripts/build_models.py b/scripts/build_models.py deleted file mode 100644 index 4f50b0a..0000000 --- a/scripts/build_models.py +++ /dev/null @@ -1,55 +0,0 @@ -from functools import partial -from pathlib import Path - -import requests -from datamodel_code_generator import InputFileType, PythonVersion, generate -from transliterate import translit - -output_dir = Path(__file__).parent / 'models_generated' -generate_model = partial(generate, input_file_type=InputFileType.Json, target_python_version=PythonVersion.PY_38) - -if __name__ == '__main__': - """ - Docs: https://2ch.hk/api/res/1.html - - -- JSON для тредов и списков - - Треды: - https://2ch.hk/доска/res/номертреда.json - Список тредов: - https://2ch.hk/доска/номерстраницы.json (первая страница: index) - Все треды с сортировкой по последнему посту: - https://2ch.hk/доска/catalog.json - Все треды с сортировкой по времени создания треда: - https://2ch.hk/доска/catalog_num.json - Все треды с доски (облегченный вариант, с просмотрами и рейтингом для топа тредов): - https://2ch.hk/доска/threads.json - - -- Мобильное API - - Получить настройки всех досок: - Пример: https://2ch.hk/makaba/mobile.fcgi?task=get_boards - Получить все посты из треда с номера поста по доске: - Пример: https://2ch.hk/makaba/mobile.fcgi?task=get_thread&board=abu&thread=39220&num=41955 - Получить все посты из треда с номера поста по треду: - Пример: https://2ch.hk/makaba/mobile.fcgi?task=get_thread&board=abu&thread=39220&post=252 - Получить один пост: - Пример: https://2ch.hk/makaba/mobile.fcgi?task=get_post&board=abu&post=41955 - """ - methods = ( - ('https://2ch.hk/api/res/1.json', 'thread'), - ('https://2ch.hk/b/index.json', 'page'), - ('https://2ch.hk/b/catalog.json', 'catalog'), - ('https://2ch.hk/b/catalog_num.json', 'catalog_by_date'), - ('https://2ch.hk/b/threads.json', 'threads'), - ('https://2ch.hk/boards.json', 'boards'), - ('https://2ch.hk/makaba/mobile.fcgi?task=get_boards', 'boards_by_types'), - ('https://2ch.hk/makaba/mobile.fcgi?task=get_thread&board=abu&thread=39220&num=41955', 'posts_by_id'), - ('https://2ch.hk/makaba/mobile.fcgi?task=get_thread&board=abu&thread=39220&post=252', 'posts_by_num'), - ('https://2ch.hk/makaba/mobile.fcgi?task=get_post&board=abu&post=41955', 'post'), - ) - - for url, name in methods: - schema = requests.get(url).text - schema = translit(schema, 'ru', reversed=True, strict=True) - generate_model(schema, output=output_dir / f'_{name}.py') diff --git a/scripts/build_readme.py b/scripts/build_readme.py deleted file mode 100644 index a760f8c..0000000 --- a/scripts/build_readme.py +++ /dev/null @@ -1,39 +0,0 @@ -import string -from pathlib import Path - - -def md_head_link(h: str) -> str: - # Clean from non-ascii - link = ''.join(filter(lambda s: s in string.printable, h)) - # And make link - link = '#' + link.lower().replace(' ', '-') - return link - - -def toc(text: str, level='##', skip_first=True) -> str: - r = f'{level} 📝 Table of Contents\n\n' - - for line in text.split('\n'): - head_level = 0 - for s in line: - if s != '#': - break - head_level += 1 - - if head_level > int(skip_first): - head = line.lstrip('#').lstrip() - r += ' ' * (head_level - 1 - int(skip_first)) + f'- [{head}]({md_head_link(head)})\n' - - return r - - -if __name__ == '__main__': - # Get template and sources of scripts - template = Path('../readme.template.md').read_text(encoding='utf-8') - scripts = {p.stem: p.read_text(encoding='utf-8').strip() for p in Path('../examples').iterdir()} - - # Substitute - result = template.format(toc=toc(template), **scripts) - - # Write result - Path('../readme.md').write_text(result, encoding='utf-8') diff --git a/scripts/models_generated/_boards.py b/scripts/models_generated/_boards.py deleted file mode 100644 index 5314c17..0000000 --- a/scripts/models_generated/_boards.py +++ /dev/null @@ -1,39 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:43+00:00 - -from __future__ import annotations - -from typing import List - -from pydantic import BaseModel - - -class Board(BaseModel): - bump_limit: int - category: str - default_name: str - enable_names: int - enable_sage: int - id: str - info: str - last_num: int - name: str - speed: int - threads: int - unique_posters: int - - -class Tag(BaseModel): - board: str - tag: str - - -class Model(BaseModel): - boards: List[Board] - global_boards: int - global_posts: str - global_speed: str - is_index: int - tags: List[Tag] - type: int diff --git a/scripts/models_generated/_boards_by_types.py b/scripts/models_generated/_boards_by_types.py deleted file mode 100644 index b2caf6a..0000000 --- a/scripts/models_generated/_boards_by_types.py +++ /dev/null @@ -1,257 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:44+00:00 - -from __future__ import annotations - -from typing import List - -from pydantic import BaseModel, Field - - -class VzroslymItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class IgryItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class Icon(BaseModel): - name: str - num: int - url: str - - -class PolitikaItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List[Icon] - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class Icon1(BaseModel): - name: str - num: int - url: str - - -class PolZovatelSkieItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List[Icon1] - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class RaznoeItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class TvorchestvoItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class Icon2(BaseModel): - name: str - num: int - url: str - - -class TematikaItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List[Icon2] - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class TehnikaISoftItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class JaponskajaKulTuraItem(BaseModel): - bump_limit: int - category: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - icons: List - id: str - name: str - pages: int - sage: int - tripcodes: int - - -class Model(BaseModel): - Vzroslym: List[VzroslymItem] - Igry: List[IgryItem] - Politika: List[PolitikaItem] - Pol_zovatel_skie: List[PolZovatelSkieItem] = Field(..., alias="Pol'zovatel'skie") - Raznoe: List[RaznoeItem] - Tvorchestvo: List[TvorchestvoItem] - Tematika: List[TematikaItem] - Tehnika_i_soft: List[TehnikaISoftItem] = Field(..., alias='Tehnika i soft') - Japonskaja_kul_tura: List[JaponskajaKulTuraItem] = Field( - ..., alias="Japonskaja kul'tura" - ) diff --git a/scripts/models_generated/_catalog.py b/scripts/models_generated/_catalog.py deleted file mode 100644 index 5c9a654..0000000 --- a/scripts/models_generated/_catalog.py +++ /dev/null @@ -1,99 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:41+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - - -class NewsAbuItem(BaseModel): - date: str - num: int - subject: str - views: int - - -class File(BaseModel): - displayname: str - fullname: str - height: int - md5: str - name: str - nsfw: int - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - duration: Optional[str] = None - duration_secs: Optional[int] = None - - -class Thread(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - files_count: int - lasthit: int - name: str - num: str - op: int - parent: str - posts_count: int - sticky: int - subject: str - tags: str - timestamp: int - trip: str - - -class TopItem(BaseModel): - board: str - info: str - name: str - - -class Model(BaseModel): - Board: str - BoardInfo: str - BoardInfoOuter: str - BoardName: str - advert_bottom_image: str - advert_bottom_link: str - advert_mobile_image: str - advert_mobile_link: str - advert_top_image: str - advert_top_link: str - board_banner_image: str - board_banner_link: str - bump_limit: int - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_images: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - enable_video: int - filter: str - max_comment: int - max_files_size: int - news_abu: List[NewsAbuItem] - threads: List[Thread] - top: List[TopItem] diff --git a/scripts/models_generated/_catalog_by_date.py b/scripts/models_generated/_catalog_by_date.py deleted file mode 100644 index 5c9a654..0000000 --- a/scripts/models_generated/_catalog_by_date.py +++ /dev/null @@ -1,99 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:41+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - - -class NewsAbuItem(BaseModel): - date: str - num: int - subject: str - views: int - - -class File(BaseModel): - displayname: str - fullname: str - height: int - md5: str - name: str - nsfw: int - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - duration: Optional[str] = None - duration_secs: Optional[int] = None - - -class Thread(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - files_count: int - lasthit: int - name: str - num: str - op: int - parent: str - posts_count: int - sticky: int - subject: str - tags: str - timestamp: int - trip: str - - -class TopItem(BaseModel): - board: str - info: str - name: str - - -class Model(BaseModel): - Board: str - BoardInfo: str - BoardInfoOuter: str - BoardName: str - advert_bottom_image: str - advert_bottom_link: str - advert_mobile_image: str - advert_mobile_link: str - advert_top_image: str - advert_top_link: str - board_banner_image: str - board_banner_link: str - bump_limit: int - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_images: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - enable_video: int - filter: str - max_comment: int - max_files_size: int - news_abu: List[NewsAbuItem] - threads: List[Thread] - top: List[TopItem] diff --git a/scripts/models_generated/_page.py b/scripts/models_generated/_page.py deleted file mode 100644 index bbf9f1a..0000000 --- a/scripts/models_generated/_page.py +++ /dev/null @@ -1,111 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:39+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - - -class NewsAbuItem(BaseModel): - date: str - num: int - subject: str - views: int - - -class File(BaseModel): - displayname: str - fullname: str - height: int - md5: str - name: str - nsfw: int - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - duration: Optional[str] = None - duration_secs: Optional[int] = None - - -class Post(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - files_count: Optional[int] = None - lasthit: int - name: str - num: str - op: int - parent: str - posts_count: Optional[int] = None - sticky: int - subject: str - tags: Optional[str] = None - timestamp: int - trip: str - - -class Thread(BaseModel): - files_count: int - posts: List[Post] - posts_count: int - thread_num: str - - -class TopItem(BaseModel): - board: str - info: str - name: str - - -class Model(BaseModel): - Board: str - BoardInfo: str - BoardInfoOuter: str - BoardName: str - advert_bottom_image: str - advert_bottom_link: str - advert_mobile_image: str - advert_mobile_link: str - advert_top_image: str - advert_top_link: str - board_banner_image: str - board_banner_link: str - board_speed: int - bump_limit: int - current_page: int - current_thread: int - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_images: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - enable_video: int - is_board: int - is_index: int - max_comment: int - max_files_size: int - news_abu: List[NewsAbuItem] - pages: List[int] - threads: List[Thread] - top: List[TopItem] diff --git a/scripts/models_generated/_post.py b/scripts/models_generated/_post.py deleted file mode 100644 index fcc6654..0000000 --- a/scripts/models_generated/_post.py +++ /dev/null @@ -1,33 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:46+00:00 - -from __future__ import annotations - -from typing import List - -from pydantic import BaseModel - - -class ModelItem(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List - lasthit: int - name: str - num: str - op: int - parent: str - sticky: int - subject: str - timestamp: int - trip: str - trip_type: str - - -class Model(BaseModel): - __root__: List[ModelItem] diff --git a/scripts/models_generated/_posts_by_id.py b/scripts/models_generated/_posts_by_id.py deleted file mode 100644 index ecb3f4e..0000000 --- a/scripts/models_generated/_posts_by_id.py +++ /dev/null @@ -1,49 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:44+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - - -class File(BaseModel): - duration: Optional[str] = None - height: int - md5: str - name: str - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - nsfw: Optional[int] = None - - -class ModelItem(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - lasthit: int - name: str - num: str - op: int - parent: str - sticky: int - subject: str - timestamp: int - trip: str - trip_type: str - unique_posters: Optional[str] = None - - -class Model(BaseModel): - __root__: List[ModelItem] diff --git a/scripts/models_generated/_posts_by_num.py b/scripts/models_generated/_posts_by_num.py deleted file mode 100644 index 6c5a1df..0000000 --- a/scripts/models_generated/_posts_by_num.py +++ /dev/null @@ -1,49 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:45+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import BaseModel - - -class File(BaseModel): - duration: Optional[str] = None - height: int - md5: str - name: str - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - nsfw: Optional[int] = None - - -class ModelItem(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - lasthit: int - name: str - num: str - op: int - parent: str - sticky: int - subject: str - timestamp: int - trip: str - trip_type: str - unique_posters: Optional[str] = None - - -class Model(BaseModel): - __root__: List[ModelItem] diff --git a/scripts/models_generated/_thread.py b/scripts/models_generated/_thread.py deleted file mode 100644 index 590a49c..0000000 --- a/scripts/models_generated/_thread.py +++ /dev/null @@ -1,109 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:38+00:00 - -from __future__ import annotations - -from typing import List - -from pydantic import BaseModel - - -class NewsAbuItem(BaseModel): - date: str - num: int - subject: str - views: int - - -class File(BaseModel): - displayname: str - fullname: str - height: int - md5: str - name: str - nsfw: int - path: str - size: int - thumbnail: str - tn_height: int - tn_width: int - type: int - width: int - - -class Post(BaseModel): - banned: int - closed: int - comment: str - date: str - email: str - endless: int - files: List[File] - lasthit: int - name: str - num: int - number: int - op: int - parent: str - sticky: int - subject: str - tags: str - timestamp: int - trip: str - - -class Thread(BaseModel): - posts: List[Post] - - -class TopItem(BaseModel): - board: str - info: str - name: str - - -class Model(BaseModel): - Board: str - BoardInfo: str - BoardInfoOuter: str - BoardName: str - advert_bottom_image: str - advert_bottom_link: str - advert_mobile_image: str - advert_mobile_link: str - advert_top_image: str - advert_top_link: str - board_banner_image: str - board_banner_link: str - bump_limit: int - current_thread: str - default_name: str - enable_dices: int - enable_flags: int - enable_icons: int - enable_images: int - enable_likes: int - enable_names: int - enable_oekaki: int - enable_posting: int - enable_sage: int - enable_shield: int - enable_subject: int - enable_thread_tags: int - enable_trips: int - enable_video: int - files_count: int - is_board: int - is_closed: int - is_index: int - max_comment: int - max_files_size: int - max_num: int - news_abu: List[NewsAbuItem] - posts_count: int - thread_first_image: str - threads: List[Thread] - title: str - top: List[TopItem] - unique_posters: str diff --git a/scripts/models_generated/_threads.py b/scripts/models_generated/_threads.py deleted file mode 100644 index 0841cc0..0000000 --- a/scripts/models_generated/_threads.py +++ /dev/null @@ -1,25 +0,0 @@ -# generated by datamodel-codegen: -# filename: -# timestamp: 2022-04-30T21:52:43+00:00 - -from __future__ import annotations - -from typing import List - -from pydantic import BaseModel - - -class Thread(BaseModel): - comment: str - lasthit: int - num: str - posts_count: int - score: float - subject: str - timestamp: int - views: int - - -class Model(BaseModel): - board: str - threads: List[Thread] diff --git a/setup.py b/setup.py deleted file mode 100644 index 3147172..0000000 --- a/setup.py +++ /dev/null @@ -1,59 +0,0 @@ -import importlib.util -import os -import sys - -import setuptools -from pkg_resources import parse_requirements - - -def read(filename: str) -> str: - with open(filename, encoding='utf-8') as file: - return file.read() - - -def get_module(name: str): - spec = importlib.util.spec_from_file_location(name, os.path.join(name, '__init__.py')) - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def load_requirements(filename: str) -> list: - requirements = [] - for req in parse_requirements(read(filename)): - extras = '[{}]'.format(','.join(req.extras)) if req.extras else '' - requirements.append( - '{}{}{}'.format(req.name, extras, req.specifier) - ) - return requirements - - -module_name = 'api2ch' -module = get_module(module_name) - -setuptools.setup( - name=module_name, - version=module.__version__, - author=module.__author__, - author_email=module.__email__, - license=module.__license__, - description=module.__doc__, - platforms='all', - long_description=read('readme.md'), - long_description_content_type='text/markdown', - url='https://github.com/uburuntu/{}'.format(module_name), - download_url='https://github.com/uburuntu/{}/archive/master.zip'.format(module_name), - packages=setuptools.find_packages(exclude=['examples', 'tests']), - install_requires=load_requirements('requirements.txt'), - extras_require={'dev': load_requirements('requirements-dev.txt')}, - keywords=['2ch', 'dvach', 'api'], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Framework :: AsyncIO', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3', - 'Typing :: Typed', - ], -) diff --git a/tests/conftest.py b/tests/conftest.py index 5fd202c..9071f02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,95 @@ +import copy +import json +from pathlib import Path + +import httpx import pytest +import pytest_asyncio from api2ch import Api2ch, Api2chAsync +@pytest.fixture(scope="session") +def wire() -> dict: + path = Path(__file__).parent / "fixtures" / "current_api.json" + return json.loads(path.read_text(encoding="utf-8")) + + @pytest.fixture -def api(): - with Api2ch() as api: - yield api +def handler(wire: dict): + board = wire["board"] + post = wire["post"] + summary = wire["summary"] + post_with_file = copy.deepcopy(post) + post_with_file["files"] = [wire["file"]] + + def response(request: httpx.Request) -> httpx.Response: + path = request.url.path + routes = { + "/api/mobile/v2/boards": [board], + "/pr/threads.json": {"board": "pr", "threads": [summary]}, + "/pr/catalog.json": { + "board": board, + "filter": "date", + "threads": [post_with_file], + }, + "/pr/catalog_num.json": {"board": board, "filter": "num", "threads": [post]}, + "/pr/index.json": { + "board": board, + "board_speed": 1, + "current_page": 0, + "current_thread": 0, + "is_board": True, + "is_index": True, + "pages": [0], + "threads": [ + {"thread_num": 100, "files_count": 0, "posts_count": 1, "posts": [post]} + ], + }, + "/pr/res/100.json": { + "board": board, + "current_thread": 100, + "files_count": 0, + "is_board": False, + "is_closed": 0, + "is_index": False, + "max_num": 100, + "posts_count": 1, + "thread_first_image": "", + "threads": [{"posts": [post]}], + "title": "Fixture subject", + "unique_posters": 1, + }, + "/api/mobile/v2/info/pr/100": { + "result": 1, + "thread": {"num": 100, "timestamp": 1700000000, "posts": 1}, + }, + "/api/mobile/v2/after/pr/100/100": {"result": 1, "unique_posters": 1, "posts": [post]}, + "/api/mobile/v2/post/pr/100": {"result": 1, "post": post}, + "/missing": {"result": 0, "error": {"code": -3, "message": "Thread is gone"}}, + "/bad-schema": {"board": 7}, + } + if path == "/malformed": + return httpx.Response(200, content=b"not json", request=request) + if path == "/http-error": + return httpx.Response(503, request=request) + payload = routes.get(path) + if payload is None: + return httpx.Response(404, request=request) + return httpx.Response(200, json=copy.deepcopy(payload), request=request) + + return response @pytest.fixture -async def api_async(): - async with Api2chAsync() as api: - yield api +def api(handler): + client = httpx.Client(transport=httpx.MockTransport(handler)) + with client, Api2ch(client=client) as value: + yield value + + +@pytest_asyncio.fixture +async def api_async(handler): + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + async with client, Api2chAsync(client=client) as value: + yield value diff --git a/tests/fixtures/current_api.json b/tests/fixtures/current_api.json new file mode 100644 index 0000000..ca3c49c --- /dev/null +++ b/tests/fixtures/current_api.json @@ -0,0 +1,74 @@ +{ + "board": { + "id": "pr", + "name": "Programming", + "category": "Technology", + "info": "Board information", + "info_outer": "Short board information", + "threads_per_page": 20, + "bump_limit": 500, + "max_pages": 10, + "default_name": "Anonymous", + "enable_names": true, + "enable_trips": true, + "enable_subject": true, + "enable_sage": true, + "enable_icons": false, + "enable_flags": false, + "enable_dices": false, + "enable_shield": true, + "enable_thread_tags": true, + "enable_posting": true, + "enable_likes": true, + "enable_oekaki": false, + "file_types": ["jpg", "png"], + "max_comment": 15000, + "max_files_size": 40960, + "future_setting": true + }, + "file": { + "name": "100.png", + "fullname": "example.png", + "displayname": "example.png", + "path": "/pr/src/100.png", + "thumbnail": "/pr/thumb/100s.png", + "md5": "fixture", + "type": 999, + "size": 2, + "width": 10, + "height": 10, + "tn_width": 5, + "tn_height": 5 + }, + "post": { + "num": 100, + "parent": 0, + "board": "pr", + "timestamp": 1700000000, + "lasthit": 1700000010, + "date": "fixture", + "email": "", + "subject": "Fixture subject", + "comment": "Fixture
body", + "files": null, + "views": 5, + "sticky": 0, + "endless": 0, + "closed": 0, + "banned": 0, + "op": 1, + "name": "Anonymous", + "trip": "", + "tags": null + }, + "summary": { + "num": 100, + "timestamp": 1700000000, + "lasthit": 1700000010, + "views": 5, + "posts_count": 1, + "score": 0.5, + "subject": "Fixture subject", + "comment": "Fixture body" + } +} diff --git a/tests/test_api.py b/tests/test_api.py index 7625e4f..aefdda3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,46 +1,65 @@ +import httpx import pytest -from api2ch import Api2ch, Api2chError +from api2ch import ( + Api2ch, + Api2chHTTPError, + Api2chResponseError, + Api2chUpstreamError, +) -class TestApi: - def test_thread(self, api: Api2ch): - api.thread('api', 1) +def test_current_endpoints(api: Api2ch) -> None: + assert api.boards()[0].id == "pr" + assert list(api.boards_by_types()) == ["Technology"] + assert api.threads("pr").sorted_by_views()[0].num == 100 + catalog = api.catalog("pr") + assert catalog.board.id == "pr" + assert catalog.threads[0].files[0].type == 999 + assert api.catalog_by_date("pr").filter == "num" + assert api.page("pr").threads[0].posts[0].files == [] + assert api.thread("pr", 100).posts[0].body_text == "Fixture\nbody" + assert api.thread_info("pr", 100).posts == 1 + assert api.posts_after("pr", 100, 100).posts[0].num == 100 + assert api.post("pr", 100).num == 100 - with pytest.raises(Api2chError): - api.thread('b', 1) - def test_threads(self, api: Api2ch): - api.threads('b').sorted_by_views() - api.threads('hw').sorted_by_posts_count() - api.threads('test').sorted_by_score() +def test_raw_namespace_bypasses_models(api: Api2ch) -> None: + result = api.raw.boards() + assert isinstance(result, list) + assert result[0]["future_setting"] is True + assert api.raw.thread_info("pr", 100)["result"] == 1 - def test_catalog(self, api: Api2ch): - api.catalog('au') - api.catalog('fiz') - api.catalog('sci') - def test_catalog_by_date(self, api: Api2ch): - api.catalog_by_date('au') - api.catalog_by_date('fiz') - api.catalog_by_date('sci') +def test_errors_are_distinct(api: Api2ch) -> None: + with pytest.raises(Api2chUpstreamError) as upstream: + api.raw.get("/missing") + assert upstream.value.code == -3 - def test_page(self, api: Api2ch): - api.page('au', 'index') - api.page('fiz', 'index') - api.page('sci', 'index') + with pytest.raises(Api2chHTTPError) as http: + api.raw.get("/http-error") + assert http.value.code == 503 - def test_boards(self, api: Api2ch): - api.boards() + with pytest.raises(Api2chResponseError): + api.raw.get("/malformed") - def test_boards_by_types(self, api: Api2ch): - api.boards_by_types() - def test_thread_posts_by_num(self, api: Api2ch): - api.thread_posts_by_num('api', 1, 1) +def test_input_validation_happens_before_transport(api: Api2ch) -> None: + with pytest.raises(ValueError): + api.thread("../pr", 100) + with pytest.raises(ValueError): + api.thread("pr", 0) - def test_thread_posts_by_post(self, api: Api2ch): - api.thread_posts_by_post('api', 1, 1) - def test_single_post(self, api: Api2ch): - api.single_post('api', 1) +def test_external_client_is_not_closed(handler) -> None: + client = httpx.Client(transport=httpx.MockTransport(handler)) + api = Api2ch(client=client) + api.close() + assert not client.is_closed + client.close() + + +def test_closing_unused_owned_client_does_not_create_one() -> None: + api = Api2ch() + api.close() + assert api._client is None diff --git a/tests/test_api_async.py b/tests/test_api_async.py index f7c705d..6e86e31 100644 --- a/tests/test_api_async.py +++ b/tests/test_api_async.py @@ -1,47 +1,24 @@ import pytest -from api2ch import Api2chAsync, Api2chError +from api2ch import Api2chAsync, Api2chUpstreamError @pytest.mark.asyncio -class TestApiAsync: - async def test_thread(self, api_async: Api2chAsync): - await api_async.thread('api', 1) +async def test_current_endpoints(api_async: Api2chAsync) -> None: + assert (await api_async.boards())[0].id == "pr" + assert (await api_async.threads("pr")).threads[0].num == 100 + assert (await api_async.catalog("pr")).board.id == "pr" + assert (await api_async.catalog_by_date("pr")).filter == "num" + assert (await api_async.page("pr")).threads[0].posts[0].files == [] + assert (await api_async.thread("pr", 100)).posts[0].num == 100 + assert (await api_async.thread_info("pr", 100)).num == 100 + assert (await api_async.posts_after("pr", 100, 100)).posts[0].num == 100 + assert (await api_async.post("pr", 100)).num == 100 - with pytest.raises(Api2chError): - await api_async.thread('b', 1) - async def test_threads(self, api_async: Api2chAsync): - (await api_async.threads('b')).sorted_by_views() - (await api_async.threads('hw')).sorted_by_posts_count() - (await api_async.threads('test')).sorted_by_score() - - async def test_catalog(self, api_async: Api2chAsync): - await api_async.catalog('au') - await api_async.catalog('fiz') - await api_async.catalog('sci') - - async def test_catalog_by_date(self, api_async: Api2chAsync): - await api_async.catalog_by_date('au') - await api_async.catalog_by_date('fiz') - await api_async.catalog_by_date('sci') - - async def test_page(self, api_async: Api2chAsync): - await api_async.page('au', 'index') - await api_async.page('fiz', 'index') - await api_async.page('sci', 'index') - - async def test_boards(self, api_async: Api2chAsync): - await api_async.boards() - - async def test_boards_by_types(self, api_async: Api2chAsync): - await api_async.boards_by_types() - - async def test_thread_posts_by_num(self, api_async: Api2chAsync): - await api_async.thread_posts_by_num('api', 1, 1) - - async def test_thread_posts_by_post(self, api_async: Api2chAsync): - await api_async.thread_posts_by_post('api', 1, 1) - - async def test_single_post(self, api_async: Api2chAsync): - await api_async.single_post('api', 1) +@pytest.mark.asyncio +async def test_raw_and_upstream_error(api_async: Api2chAsync) -> None: + assert (await api_async.raw.boards())[0]["future_setting"] is True + with pytest.raises(Api2chUpstreamError) as error: + await api_async.raw.get("/missing") + assert error.value.code == -3 diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..78d3246 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import httpx +import pytest + +from api2ch import download_file, download_file_async +from api2ch.models.file import File + + +def _file() -> File: + return File( + name="100.png", + fullname="../safe.png", + path="/pr/src/100.png", + thumbnail="/pr/thumb/100s.png", + type=1, + size=1, + width=1, + height=1, + tn_width=1, + tn_height=1, + ) + + +def test_sync_download_is_atomic_and_skippable(tmp_path: Path) -> None: + client = httpx.Client( + transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"data")) + ) + with client: + first = download_file(_file(), tmp_path, client=client) + second = download_file(_file(), tmp_path, client=client) + assert first.path == tmp_path / "safe.png" + assert first.path.read_bytes() == b"data" + assert second.skipped is True + assert not list(tmp_path.glob("*.part")) + + +@pytest.mark.asyncio +async def test_async_download(tmp_path: Path) -> None: + client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"async")) + ) + async with client: + result = await download_file_async(_file(), tmp_path / "named.bin", client=client) + assert result.path.read_bytes() == b"async" diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..3bbc217 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,50 @@ +import pytest + +from api2ch import Api2ch, Api2chAsync, Api2chError + +pytestmark = pytest.mark.integration + + +def _active_thread(api: Api2ch) -> tuple[str, int]: + available = {board.id for board in api.boards()} + for board in ("pr", "hw", "sci", "test"): + if board not in available: + continue + try: + threads = api.threads(board).threads + except Api2chError: + continue + if threads: + return board, threads[0].num + raise AssertionError("no active non-adult integration board was available") + + +def test_live_sync_contract() -> None: + with Api2ch() as api: + board, thread_id = _active_thread(api) + assert api.catalog(board).board.id == board + assert api.catalog_by_date(board).board.id == board + assert api.page(board).board.id == board + thread = api.thread(board, thread_id) + assert thread.posts[0].num == thread_id + assert api.thread_info(board, thread_id).num == thread_id + assert api.posts_after(board, thread_id, thread_id).posts[0].num == thread_id + assert api.post(board, thread_id).num == thread_id + + +@pytest.mark.asyncio +async def test_live_async_contract() -> None: + async with Api2chAsync() as api: + boards = {board.id for board in await api.boards()} + board = next(candidate for candidate in ("pr", "hw", "sci", "test") if candidate in boards) + summaries = await api.threads(board) + assert summaries.threads + thread_id = summaries.threads[0].num + assert (await api.catalog(board)).board.id == board + assert (await api.catalog_by_date(board)).board.id == board + assert (await api.page(board)).board.id == board + thread = await api.thread(board, thread_id) + assert thread.posts[0].num == thread_id + assert (await api.thread_info(board, thread_id)).num == thread_id + assert (await api.posts_after(board, thread_id, thread_id)).posts[0].num == thread_id + assert (await api.post(board, thread_id)).num == thread_id diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9f8c325 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,12 @@ +from api2ch import clear_html, convert_html, parse_url + + +def test_parse_url_accepts_current_origin_and_new_board_ids() -> None: + assert parse_url("https://2ch.su/new_board/res/123.html#456") == (True, "new_board", 123) + assert parse_url("https://example.com/pr/res/123.html") == (False, "", 0) + assert parse_url("https://2ch.su/pr") == (False, "", 0) + + +def test_text_helpers() -> None: + assert clear_html("one
two") == "one\ntwo" + assert convert_html("one
two") == "one\ntwo" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fe4b256 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1027 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "api2ch" +version = "2.0.0" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +dev = [ + { name = "build" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "twine" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.15.1,<5" }, + { name = "build", marker = "extra == 'dev'", specifier = ">=1.6,<2" }, + { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.3.1,<3" }, + { name = "pydantic", specifier = ">=2.13.5,<3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.1,<10" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4,<2" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7,<8" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.16.8,<0.17" }, + { name = "twine", marker = "extra == 'dev'", specifier = ">=7,<8" }, + { name = "typing-extensions", specifier = ">=4.15,<5" }, +] +provides-extras = ["dev"] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[package]] +name = "build" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, + { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, + { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "readme-renderer" +version = "46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-08-28T15:18:32.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-08-28T15:18:31.132Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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/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]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "twine" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +]