diff --git a/.github/workflows/build-docs.yaml b/.github/workflows/build-docs.yaml index d1125101..65d5a151 100644 --- a/.github/workflows/build-docs.yaml +++ b/.github/workflows/build-docs.yaml @@ -21,11 +21,11 @@ jobs: steps: # CHECKOUT REPOSITORY. - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. diff --git a/.github/workflows/fetch-audit-logs.yaml b/.github/workflows/fetch-audit-logs.yaml index c2230968..431bf25d 100644 --- a/.github/workflows/fetch-audit-logs.yaml +++ b/.github/workflows/fetch-audit-logs.yaml @@ -18,9 +18,10 @@ jobs: runs-on: ubuntu-latest env: - # CS TOOLS IS COMMAND LINE LIBRARY WRAPPING TS APIS - # https://thoughtspot.github.io/cs_tools/ - CS_TOOLS_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.cs_tools_version || 'v1.7.0' }} + # Stream Python stdout live (unbuffered) so long extracts show progress in the run log. + PYTHONUNBUFFERED: "1" + + # CS_TOOLS_VERSION is set at runtime by the resolve step below. CS_TOOLS_THOUGHTSPOT__URL: ${{ secrets.THOUGHTSPOT_URL }} CS_TOOLS_THOUGHTSPOT__USERNAME: ${{ secrets.THOUGHTSPOT_USERNAME }} CS_TOOLS_THOUGHTSPOT__SECRET_KEY: ${{ secrets.THOUGHTSPOT_SECRET_KEY }} @@ -40,9 +41,20 @@ jobs: " steps: + # Dispatch input wins; otherwise resolve the latest release tag via the releases/latest redirect. + - name: Resolve CS Tools version + run: | + if [ -n "${{ inputs.cs_tools_version }}" ]; then + VERSION="${{ inputs.cs_tools_version }}" + else + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/thoughtspot/cs_tools/releases/latest | sed 's#.*/tag/##')" + fi + echo "Resolved CS Tools version: $VERSION" + echo "CS_TOOLS_VERSION=$VERSION" >> "$GITHUB_ENV" + # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. @@ -52,11 +64,6 @@ jobs: - name: Install a pinned version of CS Tools run: python -m pip install "cs_tools[cli] @ https://github.com/thoughtspot/cs_tools/archive/${{ env.CS_TOOLS_VERSION }}.zip" - # ENSURE SYNCER DEPENDENCIES ARE INSTALLED. - # found in: https://github.com/thoughtspot/cs_tools/blob/master/sync//MANIFEST.json - - name: Install a pinned version of CS Tools - run: python -m pip install "snowflake-sqlalchemy >= 1.6.1" - # RUNS THE searchable tml COMMAND. # https://thoughtspot.github.io/cs_tools/tools/searchable # diff --git a/.github/workflows/fetch-bi-data.yaml b/.github/workflows/fetch-bi-data.yaml index a763ad76..2fff7b08 100644 --- a/.github/workflows/fetch-bi-data.yaml +++ b/.github/workflows/fetch-bi-data.yaml @@ -18,9 +18,10 @@ jobs: runs-on: ubuntu-latest env: - # CS TOOLS IS COMMAND LINE LIBRARY WRAPPING TS APIS - # https://thoughtspot.github.io/cs_tools/ - CS_TOOLS_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.cs_tools_version || 'v1.7.0' }} + # Stream Python stdout live (unbuffered) so long extracts show progress in the run log. + PYTHONUNBUFFERED: "1" + + # CS_TOOLS_VERSION is set at runtime by the resolve step below. CS_TOOLS_THOUGHTSPOT__URL: ${{ secrets.THOUGHTSPOT_URL }} CS_TOOLS_THOUGHTSPOT__USERNAME: ${{ secrets.THOUGHTSPOT_USERNAME }} CS_TOOLS_THOUGHTSPOT__SECRET_KEY: ${{ secrets.THOUGHTSPOT_SECRET_KEY }} @@ -40,6 +41,17 @@ jobs: " steps: + # Dispatch input wins; otherwise resolve the latest release tag via the releases/latest redirect. + - name: Resolve CS Tools version + run: | + if [ -n "${{ inputs.cs_tools_version }}" ]; then + VERSION="${{ inputs.cs_tools_version }}" + else + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/thoughtspot/cs_tools/releases/latest | sed 's#.*/tag/##')" + fi + echo "Resolved CS Tools version: $VERSION" + echo "CS_TOOLS_VERSION=$VERSION" >> "$GITHUB_ENV" + - name: Get 7 days ago run: echo "days_ago_7=$(date -d "-7 days" +'%Y-%m-%d')" >> $GITHUB_ENV @@ -48,7 +60,7 @@ jobs: # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. @@ -58,11 +70,6 @@ jobs: - name: Install a pinned version of CS Tools run: python -m pip install "cs_tools[cli] @ https://github.com/thoughtspot/cs_tools/archive/${{ env.CS_TOOLS_VERSION }}.zip" - # ENSURE SYNCER DEPENDENCIES ARE INSTALLED. - # found in: https://github.com/thoughtspot/cs_tools/blob/master/sync//MANIFEST.json - - name: Install a pinned version of CS Tools - run: python -m pip install "snowflake-sqlalchemy >= 1.6.1" - # RUNS THE searchable tml COMMAND. # https://thoughtspot.github.io/cs_tools/tools/searchable # diff --git a/.github/workflows/fetch-metdata.yaml b/.github/workflows/fetch-metdata.yaml index af2312b6..429337d3 100644 --- a/.github/workflows/fetch-metdata.yaml +++ b/.github/workflows/fetch-metdata.yaml @@ -17,9 +17,10 @@ jobs: runs-on: ubuntu-latest env: - # CS TOOLS IS COMMAND LINE LIBRARY WRAPPING TS APIS - # https://thoughtspot.github.io/cs_tools/ - CS_TOOLS_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.cs_tools_version || 'v1.7.0' }} + # Stream Python stdout live (unbuffered) so long extracts show progress in the run log. + PYTHONUNBUFFERED: "1" + + # CS_TOOLS_VERSION is set at runtime by the resolve step below. CS_TOOLS_THOUGHTSPOT__URL: ${{ secrets.THOUGHTSPOT_URL }} CS_TOOLS_THOUGHTSPOT__USERNAME: ${{ secrets.THOUGHTSPOT_USERNAME }} CS_TOOLS_THOUGHTSPOT__SECRET_KEY: ${{ secrets.THOUGHTSPOT_SECRET_KEY }} @@ -39,9 +40,20 @@ jobs: " steps: + # Dispatch input wins; otherwise resolve the latest release tag via the releases/latest redirect. + - name: Resolve CS Tools version + run: | + if [ -n "${{ inputs.cs_tools_version }}" ]; then + VERSION="${{ inputs.cs_tools_version }}" + else + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/thoughtspot/cs_tools/releases/latest | sed 's#.*/tag/##')" + fi + echo "Resolved CS Tools version: $VERSION" + echo "CS_TOOLS_VERSION=$VERSION" >> "$GITHUB_ENV" + # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. @@ -51,11 +63,6 @@ jobs: - name: Install a pinned version of CS Tools run: python -m pip install "cs_tools[cli] @ https://github.com/thoughtspot/cs_tools/archive/${{ env.CS_TOOLS_VERSION }}.zip" - # ENSURE SYNCER DEPENDENCIES ARE INSTALLED. - # found in: https://github.com/thoughtspot/cs_tools/blob/master/sync//MANIFEST.json - - name: Install a pinned version of CS Tools - run: python -m pip install "snowflake-sqlalchemy >= 1.6.1" - # RUNS THE searchable metadata COMMAND. # https://thoughtspot.github.io/cs_tools/tools/searchable # diff --git a/.github/workflows/fetch-tml.yaml b/.github/workflows/fetch-tml.yaml index 483f6c02..aacc9461 100644 --- a/.github/workflows/fetch-tml.yaml +++ b/.github/workflows/fetch-tml.yaml @@ -18,9 +18,10 @@ jobs: runs-on: ubuntu-latest env: - # CS TOOLS IS COMMAND LINE LIBRARY WRAPPING TS APIS - # https://thoughtspot.github.io/cs_tools/ - CS_TOOLS_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.cs_tools_version || 'v1.7.0' }} + # Stream Python stdout live (unbuffered) so long extracts show progress in the run log. + PYTHONUNBUFFERED: "1" + + # CS_TOOLS_VERSION is set at runtime by the resolve step below. CS_TOOLS_THOUGHTSPOT__URL: ${{ secrets.THOUGHTSPOT_URL }} CS_TOOLS_THOUGHTSPOT__USERNAME: ${{ secrets.THOUGHTSPOT_USERNAME }} CS_TOOLS_THOUGHTSPOT__SECRET_KEY: ${{ secrets.THOUGHTSPOT_SECRET_KEY }} @@ -40,9 +41,20 @@ jobs: " steps: + # Dispatch input wins; otherwise resolve the latest release tag via the releases/latest redirect. + - name: Resolve CS Tools version + run: | + if [ -n "${{ inputs.cs_tools_version }}" ]; then + VERSION="${{ inputs.cs_tools_version }}" + else + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/thoughtspot/cs_tools/releases/latest | sed 's#.*/tag/##')" + fi + echo "Resolved CS Tools version: $VERSION" + echo "CS_TOOLS_VERSION=$VERSION" >> "$GITHUB_ENV" + # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. @@ -52,11 +64,6 @@ jobs: - name: Install a pinned version of CS Tools run: python -m pip install "cs_tools[cli] @ https://github.com/thoughtspot/cs_tools/archive/${{ env.CS_TOOLS_VERSION }}.zip" - # ENSURE SYNCER DEPENDENCIES ARE INSTALLED. - # found in: https://github.com/thoughtspot/cs_tools/blob/master/sync//MANIFEST.json - - name: Install a pinned version of CS Tools - run: python -m pip install "snowflake-sqlalchemy >= 1.6.1" - # RUNS THE searchable tml COMMAND. # https://thoughtspot.github.io/cs_tools/tools/searchable # diff --git a/.github/workflows/fetch-ts-bi-data.yml b/.github/workflows/fetch-ts-bi-data.yml index d7694c1d..caf6dbdb 100644 --- a/.github/workflows/fetch-ts-bi-data.yml +++ b/.github/workflows/fetch-ts-bi-data.yml @@ -17,9 +17,10 @@ jobs: runs-on: ubuntu-latest env: - # CS TOOLS IS COMMAND LINE LIBRARY WRAPPING TS APIS - # https://thoughtspot.github.io/cs_tools/ - CS_TOOLS_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.cs_tools_version || 'v1.7.0' }} + # Stream Python stdout live (unbuffered) so long extracts show progress in the run log. + PYTHONUNBUFFERED: "1" + + # CS_TOOLS_VERSION is set at runtime by the resolve step below. CS_TOOLS_THOUGHTSPOT__URL: ${{ secrets.THOUGHTSPOT_URL }} CS_TOOLS_THOUGHTSPOT__USERNAME: ${{ secrets.THOUGHTSPOT_USERNAME }} CS_TOOLS_THOUGHTSPOT__SECRET_KEY: ${{ secrets.THOUGHTSPOT_SECRET_KEY }} @@ -29,6 +30,17 @@ jobs: # https://thoughtspot.github.io/cs_tools/syncer/snowflake/ steps: + # Dispatch input wins; otherwise resolve the latest release tag via the releases/latest redirect. + - name: Resolve CS Tools version + run: | + if [ -n "${{ inputs.cs_tools_version }}" ]; then + VERSION="${{ inputs.cs_tools_version }}" + else + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/thoughtspot/cs_tools/releases/latest | sed 's#.*/tag/##')" + fi + echo "Resolved CS Tools version: $VERSION" + echo "CS_TOOLS_VERSION=$VERSION" >> "$GITHUB_ENV" + - name: Get 7 days ago run: echo "days_ago_7=$(date -d "-7 days" +'%Y-%m-%d')" >> $GITHUB_ENV @@ -37,7 +49,7 @@ jobs: # SETUP PYTHON. - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 # UPDATE PIP. - name: Ensure pip is up to date. @@ -47,11 +59,6 @@ jobs: - name: Install a pinned version of CS Tools run: python -m pip install "cs_tools[cli] @ https://github.com/thoughtspot/cs_tools/archive/${{ env.CS_TOOLS_VERSION }}.zip" - # ENSURE SYNCER DEPENDENCIES ARE INSTALLED. - # found in: https://github.com/thoughtspot/cs_tools/blob/master/sync//MANIFEST.json - - name: Install a pinned version of CS Tools - run: python -m pip install "snowflake-sqlalchemy >= 1.6.1" - - name: Decode PEM file run: echo "${{ secrets.SNOWFLAKE_PRIVATE_KEY }}" > $P8_KEY_PATH diff --git a/.github/workflows/test-bootstrapper.yaml b/.github/workflows/test-bootstrapper.yaml index dd7c5320..60c95973 100644 --- a/.github/workflows/test-bootstrapper.yaml +++ b/.github/workflows/test-bootstrapper.yaml @@ -2,6 +2,11 @@ name: Test CS Tools Bootstrapper on: workflow_dispatch: + inputs: + bootstrapper_ref: + description: "Git ref to test the bootstrapper from. Default: the published docs-site install.py." + required: false + type: string push: branches: @@ -27,19 +32,24 @@ jobs: - "3.12" - "3.13" + env: + # DEFAULT = THE PUBLISHED INSTALLER (WHAT CUSTOMERS RUN). PASS bootstrapper_ref TO TEST A + # REF'S INSTALLER PRE-RELEASE -- install.py IS _bootstrapper.py MIRRORED AT DOCS-DEPLOY TIME. + BOOTSTRAPPER_URL: ${{ inputs.bootstrapper_ref != '' && format('https://raw.githubusercontent.com/thoughtspot/cs_tools/{0}/cs_tools/updater/_bootstrapper.py', inputs.bootstrapper_ref) || 'https://thoughtspot.github.io/cs_tools/install.py' }} + steps: # SETUP PYTHON. - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - + # USES THE COMMAND FROM THE DOCS PAGE. - if: ${{ runner.os == 'Windows' }} name: Install on Windows. - run: powershell -ExecutionPolicy ByPass -c "IRM https://thoughtspot.github.io/cs_tools/install.py | python - --reinstall" + run: powershell -ExecutionPolicy ByPass -c "IRM $env:BOOTSTRAPPER_URL | python - --reinstall" # USES THE COMMAND FROM THE DOCS PAGE. - if: ${{ runner.os != 'Windows' }} name: Install on Linux/MacOS. - run: curl -LsSf https://thoughtspot.github.io/cs_tools/install.py | python - --reinstall + run: curl -LsSf "$BOOTSTRAPPER_URL" | python - --reinstall diff --git a/.github/workflows/test-syncers.yaml b/.github/workflows/test-syncers.yaml index fc4fc748..4c915fc6 100644 --- a/.github/workflows/test-syncers.yaml +++ b/.github/workflows/test-syncers.yaml @@ -25,10 +25,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/cs_tools/__main__.py b/cs_tools/__main__.py index 0bd9b194..f8be0e5a 100644 --- a/cs_tools/__main__.py +++ b/cs_tools/__main__.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys try: from cs_tools.cli.commands.main import run @@ -16,4 +17,4 @@ ) else: - run() + sys.exit(run()) diff --git a/cs_tools/__project__.py b/cs_tools/__project__.py index b0056284..37589349 100644 --- a/cs_tools/__project__.py +++ b/cs_tools/__project__.py @@ -1,4 +1,4 @@ -__version__ = "1.7.0" +__version__ = "1.7.1" __docs__ = "https://thoughtspot.github.io/cs_tools/" __repo__ = "https://github.com/thoughtspot/cs_tools" __help__ = f"{__repo__}/discussions/" diff --git a/cs_tools/api/workflows/metadata.py b/cs_tools/api/workflows/metadata.py index 6eceb858..bc78410d 100644 --- a/cs_tools/api/workflows/metadata.py +++ b/cs_tools/api/workflows/metadata.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Coroutine, Iterable -from typing import Any, Literal, Optional, cast +from collections.abc import Coroutine, Iterable, Sequence +from typing import Any, Literal, NamedTuple, Optional, cast import asyncio import datetime as dt import itertools as it @@ -9,10 +9,10 @@ import logging import pathlib -from tenacity import before_sleep_log, retry, retry_if_exception_type, stop_after_attempt, wait_fixed from thoughtspot_tml.types import TMLObject import awesomeversion import httpx +import tenacity from cs_tools import _types, utils from cs_tools.api.client import RESTAPIClient @@ -74,59 +74,185 @@ def _flatten_identifiers(guids: Iterable[Any]) -> list[_types.GUID]: return flat -retry_on_httpx_errors = retry( - stop=stop_after_attempt(3), # Retry up to 3 times - wait=wait_fixed(2), # Wait 2 seconds between retries - retry=retry_if_exception_type((httpx.ReadError, httpx.ConnectTimeout, httpx.HTTPStatusError)), - before_sleep=before_sleep_log(_LOG, logging.INFO), # Log before sleeping - reraise=True, # Reraise the exception if all retries fail -) +class FetchFailure(NamedTuple): + """One batched API request which could not be fetched.""" + + metadata_type: str + identifiers: tuple[_types.GUID, ...] + error: BaseException + fetched: str = "data" + """What the request was fetching (dependents, details, permissions) -- objects of the same + type are fetched by multiple phases, so the type alone cannot say what was lost.""" + + +class _FailureDigest: + """ + Report request failures on the console as they happen. + + A failure storm (eg. a server stuck returning 502s) can fail thousands of requests in a single + phase. One ERROR line per failure at that scale buries the phase warning and the end-of-run + summary, so the console gets the first few failures in detail and a periodic tally after that. + The logfile always receives every failure in full. + """ + + DETAIL_LIMIT = 5 + TALLY_EVERY = 250 + + def __init__(self, fetched: str = "data") -> None: + self.fetched = fetched + self.n_failed = 0 + + def record(self, metadata_type: str, identifiers: tuple[_types.GUID, ...], error: httpx.HTTPError) -> None: + self.n_failed += 1 + + if self.n_failed <= self.DETAIL_LIMIT: + # SOME ERRORS (eg. ReadTimeout) CARRY NO MESSAGE -- DON'T RENDER A DANGLING COLON. + described = f"{type(error).__name__}: {error}" if f"{error}" else type(error).__name__ + _LOG.error( + f"Could not fetch {self.fetched} for {len(identifiers)} {metadata_type} objects " + f"({described}), the extract will continue without them.." + ) + elif self.n_failed % self.TALLY_EVERY == 0: + _LOG.error(f"{self.n_failed:,} API requests have failed so far, the extract will continue..") + + _LOG.debug(f"Full error: {error}", exc_info=True) + + +async def _observed_request( + coro: Coroutine, + metadata_type: str, + identifiers: tuple[_types.GUID, ...], + digest: _FailureDigest, +) -> httpx.Response: + """Await one batched request, reporting a failure the moment it happens.""" + try: + try: + r = await coro + except tenacity.RetryError as error: + # A SERVER STUCK ON PRESSURE STATUSES (429/502/503/504) EXHAUSTS THE RESULT-BASED RETRY + # POLICY, WHICH SURFACES AS tenacity.RetryError WRAPPING THE LAST RESPONSE (reraise=True + # ONLY COVERS EXCEPTION-BASED EXHAUSTION). UNWRAP IT SO IT FLOWS THROUGH THE SAME + # STATUS-FAILURE PATH AS ANY OTHER ERROR RESPONSE. + if error.last_attempt.failed: + raise + r = error.last_attempt.result() + + r.raise_for_status() + + except httpx.HTTPError as e: + digest.record(metadata_type, identifiers, e) + raise + + return r + + +def _sift_gathered_outcomes( + batches: list[tuple[str, tuple[_types.GUID, ...]]], + outcomes: Sequence[Any], + *, + failures: Optional[list[FetchFailure]] = None, + fetched: str = "data", +) -> list[Any]: + """ + Split gathered request outcomes into their parsed payloads, reporting the failed batches. + + Failures were already reported on the console as they happened (see `_observed_request`). Here + they are tallied into the phase warning, and reported to `failures` when the caller passes a + collector, so it can report the gaps and decide whether an incomplete result is acceptable. + """ + payloads: list[Any] = [] + missing_by_type: dict[str, int] = {} + n_failed = 0 + + for (metadata_type, identifiers), outcome in zip(batches, outcomes): + # ONLY TRANSPORT AND STATUS FAILURES ARE TOLERATED. ANYTHING ELSE IS A BUG IN OUR OWN CODE + # AND MUST STAY LOUD, RATHER THAN BECOMING A SILENTLY INCOMPLETE EXTRACT. + if isinstance(outcome, httpx.HTTPError): + missing_by_type[metadata_type] = missing_by_type.get(metadata_type, 0) + len(identifiers) + n_failed += 1 + + if failures is not None: + failures.append( + FetchFailure(metadata_type=metadata_type, identifiers=identifiers, error=outcome, fetched=fetched) + ) + + continue + + if isinstance(outcome, BaseException): + raise outcome + + payloads.append(outcome.json()) + + if n_failed: + *rest, last = [f"{count:,} {metadata_type}" for metadata_type, count in missing_by_type.items()] + missing = f"{', '.join(rest)} and {last}" if rest else last + + # ONLY THE CALLERS WHICH COLLECT FAILURES REPORT AN END-OF-RUN SUMMARY. + followup = " A summary follows at the end of the run." if failures is not None else "" + + _LOG.warning( + f"{n_failed:,} of {len(batches):,} API requests failed after retries. The extract will " + f"continue, but {fetched} for up to {missing} objects will be missing from this run. Each " + f"failure is recorded in the logfile.{followup}" + ) + + return payloads -@retry_on_httpx_errors async def fetch( typed_guids: dict[_types.APIObjectType, Iterable[_types.GUID]], *, http: RESTAPIClient, record_size: int = 5_000, + failures: Optional[list[FetchFailure]] = None, **search_options, ) -> list[_types.APIResult]: - """Wraps metadata/search fetching specific objects and exhausts the pagination.""" - CONCURRENCY_MAGIC_NUMBER = 10 # Why? In case **search_options contains - - results: list[_types.APIResult] = [] - tasks: list[asyncio.Task] = [] - - _LOG.info(f"Max concurrent tasks in fetch func: {CONCURRENCY_MAGIC_NUMBER}") - - async with utils.BoundedTaskGroup(max_concurrent=CONCURRENCY_MAGIC_NUMBER) as g: - for metadata_type, guids in typed_guids.items(): - # CALLERS GROUP IDENTIFIERS INCONSISTENTLY (single guids, or per-object lists). FLATTEN - # THEM, THEN RE-BATCH TO A BOUNDED SIZE SO REQUEST COST STAYS PREDICTABLE REGARDLESS OF - # HOW WIDE OR NUMEROUS THE OBJECTS ARE. - for batch in utils.batched(_flatten_identifiers(guids), n=_MAX_IDENTIFIERS_PER_SEARCH): - options = {**search_options, "metadata": [{"type": metadata_type, "identifier": _} for _ in batch]} - coro = http.metadata_search(guid="", record_size=record_size, **options) - task = g.create_task(coro, name=f"{metadata_type} [{len(batch)} ids]") - tasks.append(task) + """ + Wraps metadata/search fetching specific objects. - for task in tasks: - try: - r = task.result() - r.raise_for_status() - d = r.json() + A batch which cannot be fetched -- after the client's own retries are exhausted -- is skipped + instead of cancelling its siblings. Dependent lists are unbounded and cannot be paginated, so a + request can always exceed the read timeout; one dropped batch must not cost the entire extract. - except httpx.ReadError as e: - _LOG.error(f"ReadError for guid={task.get_name()}, see logs for details..") - _LOG.debug(f"Full error: {e}", exc_info=True) - continue + Skipped batches are always logged, and are reported to `failures` when the caller passes a + collector, so it can report the gaps and decide whether an incomplete result is acceptable. + """ + # WHY 10? metadata/search IS THE HEAVIEST READ WE MAKE, AND THE TRANSPORT'S OWN LIMIT IS SHARED + # WITH EVERY OTHER CALLER. HOLD BACK A FEW SLOTS SO ONE PHASE CAN'T MONOPOLISE THE CLIENT. + CONCURRENCY_MAGIC_NUMBER = 10 + + # NAME WHAT THIS CALL FETCHES, SO FAILURE MESSAGES CAN SAY WHAT WAS LOST -- NOT JUST WHICH + # OBJECT TYPE (THE SAME OBJECTS ARE FETCHED BY THE DETAILS, DEPENDENTS, AND ACCESS PHASES). + if search_options.get("include_dependent_objects"): + fetched = "dependents" + elif search_options.get("include_details"): + fetched = "details" + else: + fetched = "data" + + batches: list[tuple[_types.APIObjectType, tuple[_types.GUID, ...]]] = [] + coros: list[Coroutine] = [] + digest = _FailureDigest(fetched=fetched) + + for metadata_type, guids in typed_guids.items(): + # CALLERS GROUP IDENTIFIERS INCONSISTENTLY (single guids, or per-object lists). FLATTEN + # THEM, THEN RE-BATCH TO A BOUNDED SIZE SO REQUEST COST STAYS PREDICTABLE REGARDLESS OF + # HOW WIDE OR NUMEROUS THE OBJECTS ARE. + for batch in utils.batched(_flatten_identifiers(guids), n=_MAX_IDENTIFIERS_PER_SEARCH): + identifiers = tuple(batch) + options = {**search_options, "metadata": [{"type": metadata_type, "identifier": _} for _ in identifiers]} + c = http.metadata_search(guid="", record_size=record_size, **options) + coros.append(_observed_request(c, metadata_type, identifiers, digest)) + batches.append((metadata_type, identifiers)) + + # return_exceptions=True IS THE WHOLE POINT: a TaskGroup WOULD CANCEL EVERY SIBLING ON THE + # FIRST FAILURE, WHICH IS WHAT USED TO THROW AWAY ~25 MINUTES OF COMPLETED WORK. + outcomes = await utils.bounded_gather(*coros, max_concurrent=CONCURRENCY_MAGIC_NUMBER, return_exceptions=True) - except httpx.HTTPError as e: - _LOG.error(f"Could not fetch the object for guid={task.get_name()}, see logs for details..") - _LOG.debug(f"Full error: {e}", exc_info=True) - continue + results: list[_types.APIResult] = [] - results.extend(d) + for payload in _sift_gathered_outcomes(batches, outcomes, failures=failures, fetched=fetched): + results.extend(payload) return results @@ -222,10 +348,18 @@ async def permissions( *, compat_ts_version: awesomeversion.AwesomeVersion, record_size: int = -1, + failures: Optional[list[FetchFailure]] = None, http: RESTAPIClient, **permission_options, ) -> list[_types.APIResult]: - """Wraps security/metadata/fetch-permissions fetching specific objects and exhausts the pagination.""" + """ + Wraps security/metadata/fetch-permissions fetching specific objects. + + Permissions is the most server-expensive data we fetch, and it runs last -- a request which + fails after the client's own retries is skipped instead of cancelling its siblings, so one bad + request cannot throw away every phase which already succeeded before it. Skipped requests are + always logged, and are reported to `failures` when the caller passes a collector. + """ FIFTEEN_MINUTES = 60 * 15 if compat_ts_version < "10.3.0": @@ -233,62 +367,53 @@ async def permissions( else: CONCURRENCY_MAGIC_NUMBER = 5 # Why? Fetching permissions could potentially be very expensive for the server. - results: list[_types.APIResult] = [] - tasks: list[asyncio.Task] = [] - - async with utils.BoundedTaskGroup(max_concurrent=CONCURRENCY_MAGIC_NUMBER) as g: - for metadata_type, guids in typed_guids.items(): - for _, guid in enumerate(guids): - # DEV NOTE: @boonhapus, 2024/11/25 - # 10.3.0 IS WHEN WE RELEASED .permission_type={DEFINED|EFFECTIVE} FOR THE - # ENDPOINT security/metadata/fetch-permissions , PRIOR TO THIS, THE DEFAULT - # WAS TO FETCH EFFECTIVE PERMISSIONS. - # - # ONCE 10.3.0.SW IS N-2, WE CAN SWITCH FROM typed_guids -> guids . - # - if compat_ts_version < "10.3.0": - # A SINGLE OBJECT - if isinstance(guid, str): - permission_options["id"] = [guid] - - # AN ARRAY OF OBJECTS - if isinstance(guid, list): - permission_options["id"] = guid - - c = http.v1_security_metadata_permissions( - guid="", api_object_type=metadata_type, **permission_options - ) - # // - else: - permission_options["timeout"] = FIFTEEN_MINUTES - - # A SINGLE OBJECT - if isinstance(guid, str): - permission_options["metadata"] = [{"type": metadata_type, "identifier": guid}] - - # AN ARRAY OF OBJECTS - if isinstance(guid, list): - permission_options["metadata"] = [{"type": metadata_type, "identifier": _} for _ in guid] - - c = http.security_metadata_permissions(guid="", record_size=record_size, **permission_options) - - t = g.create_task(c, name=guid) - tasks.append(t) - - for task in tasks: - try: - r = task.result() - r.raise_for_status() - d = r.json() - - except httpx.HTTPError as e: - _LOG.error(f"Could not fetch the permissions for guid={task.get_name()}, see logs for details..") - _LOG.debug(f"Full error: {e}", exc_info=True) - continue - - results.append(d) + batches: list[tuple[_types.APIObjectType, tuple[_types.GUID, ...]]] = [] + coros: list[Coroutine] = [] + digest = _FailureDigest(fetched="permissions") - return results + for metadata_type, guids in typed_guids.items(): + for guid in guids: + # DEV NOTE: @boonhapus, 2024/11/25 + # 10.3.0 IS WHEN WE RELEASED .permission_type={DEFINED|EFFECTIVE} FOR THE + # ENDPOINT security/metadata/fetch-permissions , PRIOR TO THIS, THE DEFAULT + # WAS TO FETCH EFFECTIVE PERMISSIONS. + # + # ONCE 10.3.0.SW IS N-2, WE CAN SWITCH FROM typed_guids -> guids . + # + if compat_ts_version < "10.3.0": + # A SINGLE OBJECT + if isinstance(guid, str): + permission_options["id"] = [guid] + + # AN ARRAY OF OBJECTS + if isinstance(guid, list): + permission_options["id"] = guid + + c = http.v1_security_metadata_permissions(guid="", api_object_type=metadata_type, **permission_options) + # // + else: + permission_options["timeout"] = FIFTEEN_MINUTES + + # A SINGLE OBJECT + if isinstance(guid, str): + permission_options["metadata"] = [{"type": metadata_type, "identifier": guid}] + + # AN ARRAY OF OBJECTS + if isinstance(guid, list): + permission_options["metadata"] = [{"type": metadata_type, "identifier": _} for _ in guid] + + c = http.security_metadata_permissions(guid="", record_size=record_size, **permission_options) + + identifiers = (guid,) if isinstance(guid, str) else tuple(guid) + coros.append(_observed_request(c, metadata_type, identifiers, digest)) + batches.append((metadata_type, identifiers)) + + # return_exceptions=True: a TaskGroup WOULD CANCEL EVERY SIBLING ON THE FIRST FAILURE, + # DESTROYING THE ENTIRE EXTRACT IN ITS FINAL PHASE. + outcomes = await utils.bounded_gather(*coros, max_concurrent=CONCURRENCY_MAGIC_NUMBER, return_exceptions=True) + + # ONE PAYLOAD PER SURVIVING REQUEST -- THE SHAPE THE PERMISSIONS TRANSFORMER EXPECTS. + return _sift_gathered_outcomes(batches, outcomes, failures=failures, fetched="permissions") async def dependents(guid: _types.GUID, *, http: RESTAPIClient) -> list[_types.APIResult]: diff --git a/cs_tools/cli/_logging.py b/cs_tools/cli/_logging.py index 3682b1c5..3a272cb1 100644 --- a/cs_tools/cli/_logging.py +++ b/cs_tools/cli/_logging.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Optional import datetime as dt import logging import logging.config @@ -81,6 +82,14 @@ def filter(self, record: logging.LogRecord) -> bool: return True +def active_logfile() -> Optional[str]: + """The path of this run's logfile, if file logging is configured.""" + # MATCH OUR OWN HANDLER CLASS, NOT ANY FileHandler -- HOST PROCESSES (eg. pytest) ATTACH + # THEIR OWN FILE HANDLERS TO THE ROOT LOGGER, INCLUDING ONES AIMED AT THE NULL DEVICE. + handler = next((h for h in logging.getLogger().handlers if isinstance(h, LimitedFileHistoryHandler)), None) + return handler.baseFilename if handler is not None else None + + def _setup_logging() -> None: """Setup CLI / application logging.""" now = dt.datetime.now(tz=dt.timezone.utc).strftime("%Y-%m-%dT%H_%M_%S") diff --git a/cs_tools/cli/custom_types.py b/cs_tools/cli/custom_types.py index 6c9b545d..b0f5b60f 100644 --- a/cs_tools/cli/custom_types.py +++ b/cs_tools/cli/custom_types.py @@ -190,7 +190,7 @@ def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[cl if issubclass(SyncerClass, base.DatabaseSyncer) and self.models is not None: syncer_options["models"] = self.models - _LOG.info(f"Initializing syncer: {SyncerClass}") + _LOG.info(f"Initializing syncer: {SyncerClass.__name__}") syncer = SyncerClass(**syncer_options) # CLEAN UP DATABASE RESOURCES. diff --git a/cs_tools/cli/progress.py b/cs_tools/cli/progress.py index 42274e93..8c6b9a22 100644 --- a/cs_tools/cli/progress.py +++ b/cs_tools/cli/progress.py @@ -12,6 +12,7 @@ from rich.console import Console, Group, RenderableType from rich.progress import BarColumn, Task, TimeElapsedColumn from rich.table import Column, Table +from rich.text import Text from cs_tools import _compat, utils from cs_tools.cli.ux import RICH_CONSOLE @@ -45,6 +46,8 @@ def __init__( self.start_time: Optional[float] = None self.stop_time: Optional[float] = None self.finished_time: Optional[float] = None + self.skip_reason: Optional[str] = None + self.skipped: bool = False self._previously_elapsed: float = 0 self._prog_bar = BarColumn() @@ -52,6 +55,7 @@ def __init__( def __enter__(self) -> _compat.Self: self.start() + log.info(f"→ {self._log_label()}") return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: @@ -59,6 +63,18 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: return self.stop() + # This entry's duration only -- NOT self.elapsed, which accumulates across re-entries + # (the metadata command re-enters the same task once per org). + duration = (self.stop_time or self.get_time()) - self.start_time + + if exc_type is not None: + log.info(f"✗ {self._log_label()} failed after {duration:.1f}s") + else: + log.info(f"✓ {self._log_label()} ({duration:.1f}s)") + + def _log_label(self) -> str: + """Plain-text phase label for logs (rich markup and indentation stripped).""" + return Text.from_markup(self.description).plain.strip() @property def started(self) -> bool: @@ -98,6 +114,8 @@ def start(self, total: Optional[float] = INFINITY) -> None: self.finished_time = None self.start_time = self.get_time() + self.skipped = False + self.skip_reason = None if total is not INFINITY: self.total = total @@ -107,9 +125,12 @@ def stop(self) -> None: self.stop_time = self.get_time() self.total = -1 if self.total is None else self.total - def skip(self) -> None: - """Skip the task.""" + def skip(self, reason: Optional[str] = None) -> None: + """Skip the task, rendering it as deliberately skipped rather than never-reached.""" self.start_time = None + self.skipped = True + self.skip_reason = reason + log.info(f"⤼ {self._log_label()} skipped" + (f" ({reason})" if reason else "")) def advance(self, step: float) -> None: """Advance the task by the step value.""" @@ -143,9 +164,18 @@ def __render__(self, text_width: int, bar_width: int) -> RenderableType: self._prog_bar.bar_width = bar_width + if self.started: + bar_cell: RenderableType = self._prog_bar(cast(Task, self)) + elif self.skipped: + # A DELIBERATE SKIP MUST NOT LOOK LIKE A PHASE THAT WAS NEVER REACHED ("--"). + reason = f" -- {self.skip_reason}" if self.skip_reason else "" + bar_cell = Text.from_markup(f"[fg-warn]skipped{reason}[/]") + else: + bar_cell = "--" + table.add_row( self.description, - self._prog_bar(cast(Task, self)) if self.started else "--", + bar_cell, self._prog_elasped(cast(Task, self)), ) @@ -240,3 +270,8 @@ def __enter__(self) -> WorkTracker: def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.refresh() + # DEV NOTE: start() hid the cursor (rich Live.show_cursor(False)); stop() restores it and + # halts the refresh thread. Without this the cursor stays hidden after exit -- harmless on + # lenient local terminals, but it persists in strict emulators (e.g. cloud shells). The + # tracker isn't transient, so the final frame remains on screen. + self.stop() diff --git a/cs_tools/cli/tools/scriptability/app.py b/cs_tools/cli/tools/scriptability/app.py index 6f506202..8abbf886 100644 --- a/cs_tools/cli/tools/scriptability/app.py +++ b/cs_tools/cli/tools/scriptability/app.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections.abc import Callable +import dataclasses import datetime as dt +import importlib.metadata import json import logging import pathlib @@ -26,6 +29,87 @@ _DOCS_MAPPING = "https://developers.thoughtspot.com/docs/deploy-with-tml-apis#guidMapping" +@dataclasses.dataclass +class _TMLParseFailure: + """A TML document which the installed thoughtspot_tml library cannot parse.""" + + source: str + error: thoughtspot_tml.exceptions.TMLError + + @property + def reason(self) -> str: + """Why parsing failed, pointing at TML spec drift when that is the cause.""" + parent = getattr(self.error, "parent_exc", None) + + # THOUGHTSPOT ADDS ATTRIBUTES TO THE TML SPEC FASTER THAN thoughtspot_tml SHIPS SPEC + # REGENERATIONS -- THE FIX IS A LIBRARY UPDATE, NOT A CHANGE TO THE CUSTOMER'S FILE. + if isinstance(parent, TypeError) and " unexpected keyword argument " in str(parent): + _, _, attribute = str(parent).partition(" unexpected keyword argument ") + version = importlib.metadata.version("thoughtspot_tml") + return ( + f"it uses TML attributes newer than the installed thoughtspot_tml {version} " + f"(unrecognized attribute: {attribute})" + ) + + return str(self.error) + + +def _load_tml_files( + paths: list[pathlib.Path], +) -> tuple[list[tuple[pathlib.Path, _types.TMLObject]], list[_TMLParseFailure]]: + """Parse TML files, collecting parse failures per file instead of raising on the first.""" + loaded: list[tuple[pathlib.Path, _types.TMLObject]] = [] + failures: list[_TMLParseFailure] = [] + + for path in paths: + TML = thoughtspot_tml.utils.determine_tml_type(path=path) + + try: + loaded.append((path, TML.load(path=path))) + except thoughtspot_tml.exceptions.TMLDecodeError as e: + failures.append(_TMLParseFailure(source=str(path), error=e)) + + return loaded, failures + + +def _find_unparseable_exports(results: list[_types.APIResult]) -> list[_TMLParseFailure]: + """Detect exported edocs which the installed thoughtspot_tml cannot parse.""" + failures: list[_TMLParseFailure] = [] + + for result in results: + if result.get("edoc") is None: + continue + + try: + TML = thoughtspot_tml.utils.determine_tml_type(info=result["info"]) + TML.loads(result["edoc"]) + except thoughtspot_tml.exceptions.TMLError as e: + i = result["info"] + failures.append(_TMLParseFailure(source=f"{i.get('name', '')} ({i['id']})", error=e)) + + return failures + + +# A DRIFT EVENT AFFECTS EVERY OBJECT TOUCHED SINCE THE SERVER UPGRADE -- EASILY HUNDREDS OF +# FILES. ONE CONSOLE LINE PER FAILURE AT THAT SCALE BURIES THE SUMMARY (FULL DETAIL STILL IN THE LOGFILE). +_FAILURE_DETAIL_LIMIT = 5 + + +def _log_failures_capped( + failures: list[_TMLParseFailure], *, level: int, describe: Callable[[_TMLParseFailure], str] +) -> None: + """Log the first few failures in detail and a count of the rest; the logfile gets the full list.""" + for failure in failures[:_FAILURE_DETAIL_LIMIT]: + _LOG.log(level, describe(failure)) + + if (n_extra := len(failures) - _FAILURE_DETAIL_LIMIT) > 0: + _LOG.log(level, f"..and {n_extra:,} more, see the logs for the full list.") + + # THE CONSOLE HANDLER IS INFO+ AND THE FILE HANDLER IS DEBUG+, SO THESE REACH ONLY THE LOGFILE. + for failure in failures[_FAILURE_DETAIL_LIMIT:]: + _LOG.debug(describe(failure)) + + @app.command(name="export", hidden=True, help="This name is deprecated, but kept for discoverability.") @app.command(name="commit", hidden=True, help="This name implies we're a VCS (nope!), but kept to mirror the git tool.") @app.command() @@ -209,6 +293,20 @@ def checkpoint( # RECORD THE GUID MAPPING mapping_info.save() + # EXPORT WRITES RAW TEXT AND SUCCEEDS EVEN WHEN THE SERVER'S TML SPEC IS NEWER THAN THE + # INSTALLED thoughtspot_tml -- THOSE FILES ONLY FAIL AT DEPLOY TIME, SO FLAG THEM NOW. + if landmines := _find_unparseable_exports(_): + _log_failures_capped( + landmines, + level=logging.WARNING, + describe=lambda failure: f"Exported {failure.source}, but {failure.reason}", + ) + + _LOG.warning( + f"{len(landmines)} exported TML file(s) cannot be parsed by the installed thoughtspot_tml " + f"and will fail to deploy until the library is updated." + ) + if table.job_status != "OK": _LOG.error("One or more TMLs failed to fully export, check the logs or use --log-errors for more details.") return 1 @@ -333,6 +431,7 @@ def deploy( return 1 tmls: dict[_types.GUID, _types.TMLObject] = {} + candidates: list[pathlib.Path] = [] for path in directory.rglob("*.tml"): last_modified_time = dt.datetime.fromtimestamp(path.stat().st_mtime, tz=dt.timezone.utc) @@ -340,8 +439,25 @@ def deploy( if deploy_type == "DELTA" and last_modified_time < last_import_dt: continue - TML = thoughtspot_tml.utils.determine_tml_type(path=path) - tml = TML.load(path=path) + candidates.append(path) + + loaded, parse_failures = _load_tml_files(candidates) + + _log_failures_capped( + parse_failures, + level=logging.ERROR, + describe=lambda failure: f"Could not parse '{failure.source}', {failure.reason}", + ) + + if parse_failures and deploy_policy == "ALL_OR_NONE": + _LOG.error( + f"Refusing to deploy: {len(parse_failures)} TML file(s) could not be parsed and IMPORT POLICY " + f"'ALL_OR_NONE' requires the complete set. Fix or remove these files, or deploy with " + f"--deploy-policy PARTIAL to skip them." + ) + return 1 + + for path, tml in loaded: assert tml.guid is not None, f"Could not find a guid for {path}" if tml.tml_type_name.upper() not in input_types: @@ -351,6 +467,10 @@ def deploy( tmls[guid] = mapping_info.disambiguate(tml=tml, delete_unmapped_guids=True) if not tmls: + if parse_failures: + _LOG.error("Every TML file found to deploy failed to parse.") + return 1 + _LOG.info( f"No TML files found to deploy from directory (Deploy Type: {deploy_type}, Last Seen: {last_import_dt})" ) @@ -427,10 +547,19 @@ def deploy( c = workflows.metadata.tag_all(guids_to_tag, tags=tags, color="#A020F0", http=ts.api) # ThoughtSpot Purple :~) _ = utils.run_sync(c) + if parse_failures: + _LOG.error( + f"Skipped {len(parse_failures)} TML file(s) that could not be parsed, " + f"see the start of this run or the logs for the full list." + ) + if table.job_status == "ERROR": _LOG.error("One or more TMLs failed to fully deploy, check the logs or use --log-errors for more details.") return 1 + if parse_failures: + return 1 + if table.job_status == "WARNING": _LOG.warning( "TMLs imported succesfully with one or more warnings. Check the logs or use --log-errors for more details." diff --git a/cs_tools/cli/tools/searchable/api_transformer.py b/cs_tools/cli/tools/searchable/api_transformer.py index 3b25aedf..f6a4f19c 100644 --- a/cs_tools/cli/tools/searchable/api_transformer.py +++ b/cs_tools/cli/tools/searchable/api_transformer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any import datetime as dt import functools as ft @@ -79,8 +80,8 @@ def ts_user(data: list[_types.APIResult], *, cluster: _types.GUID) -> _types.Tab email=result["email"], display_name=result["display_name"], sharing_visibility=result["visibility"], - created=result["creation_time_in_millis"] / 1000, - modified=result["modification_time_in_millis"] / 1000, + created=validators.utc_from_millis(result.get("creation_time_in_millis")), + modified=validators.utc_from_millis(result.get("modification_time_in_millis")), user_type=result["account_type"], user_author_guid=result["author_id"], ).model_dump() @@ -106,8 +107,8 @@ def ts_group(data: list[_types.APIResult], *, cluster: _types.GUID) -> _types.Ta description=result["description"], display_name=result["display_name"], sharing_visibility=result["visibility"], - created=result["creation_time_in_millis"] / 1000, - modified=result["modification_time_in_millis"] / 1000, + created=validators.utc_from_millis(result.get("creation_time_in_millis")), + modified=validators.utc_from_millis(result.get("modification_time_in_millis")), group_type=result["type"], ).model_dump() ) @@ -130,8 +131,8 @@ def to_group_v1(data: ArbitraryJsonFormat, cluster: str) -> TableRowsFormat: description=row["header"].get("description"), display_name=row["header"]["displayName"], sharing_visibility=row["visibility"], - created=row["header"]["created"] / 1000, - modified=row["header"]["modified"] / 1000, + created=validators.utc_from_millis(row["header"].get("created")), + modified=validators.utc_from_millis(row["header"].get("modified")), group_type=row["type"], ) ) @@ -275,8 +276,8 @@ def ts_tag(data: list[_types.APIResult], *, cluster: _types.GUID, current_org: i tag_guid=result["id"], tag_name=result["name"], author_guid=result["author_id"], - created=result["creation_time_in_millis"] / 1000, - modified=result["modification_time_in_millis"] / 1000, + created=validators.utc_from_millis(result.get("creation_time_in_millis")), + modified=validators.utc_from_millis(result.get("modification_time_in_millis")), color=result["color"], ).model_dump() ) @@ -302,8 +303,8 @@ def ts_data_source(data: list[_types.APIResult], *, cluster: _types.GUID) -> _ty name=result["metadata_name"], description=result["metadata_header"].get("description", None), author=result["metadata_header"].get("author", None), - created=result["metadata_header"].get("created", None) / 1000, - modified=result["metadata_header"].get("modified", None) / 1000, + created=validators.utc_from_millis(result["metadata_header"].get("created")), + modified=validators.utc_from_millis(result["metadata_header"].get("modified")), ).model_dump() ) @@ -330,8 +331,8 @@ def ts_metadata_object(data: list[_types.APIResult], *, cluster: _types.GUID) -> name=result["metadata_name"], description=result["metadata_header"].get("description", None), author_guid=result["metadata_header"]["author"], - created=result["metadata_header"]["created"] / 1000, - modified=result["metadata_header"]["modified"] / 1000, + created=validators.utc_from_millis(result["metadata_header"].get("created")), + modified=validators.utc_from_millis(result["metadata_header"].get("modified")), object_type=result["metadata_type"], object_subtype="MODEL" if is_worksheetv2 else result["metadata_header"].get("type", None), data_source_guid=(result["metadata_detail"] or {}).get("dataSourceId", None), @@ -450,7 +451,10 @@ def ts_column_synonym(data: list[_types.APIResult], *, cluster: _types.GUID) -> for column in result["metadata_detail"]["columns"]: for synonym in column["synonyms"]: if (unique := f"{column['header']['id']}-{synonym}") in seen: - log.warning(f"Duplicate synonym found '{synonym}' for COLUMN ({column['header']['id']})") + log.warning( + f"Duplicate synonym found '{synonym}'. " + f"[COLUMN {column['header']['id']} IN TABLE {result['metadata_id']}]" + ) continue reshaped.append( @@ -466,36 +470,48 @@ def ts_column_synonym(data: list[_types.APIResult], *, cluster: _types.GUID) -> return reshaped -def ts_metadata_dependent(data: list[_types.APIResult], *, cluster: _types.GUID) -> _types.TableRowsFormat: - """Reshapes metadata/search?type=LOGICAL_COLUMN&include_dependent_objects=True -> searchable.models.MetadataObject.""" # noqa: E501 - reshaped: _types.TableRowsFormat = [] - +def _iter_dependents(data: list[_types.APIResult]) -> Iterator[tuple[_types.GUID, str, dict[str, Any]]]: + """Flatten the nested dependent_objects shape into (column_guid, dependent_type, dependent).""" for result in data: + column_guid = result["metadata_id"] for dependents_info in result["dependent_objects"].values(): for dependent_type, dependents in dependents_info.items(): for dependent in dependents: - reshaped.append( - # DEV NOTE: @boonhapus, 2024/12/06 - # There are typically an order of magnitude MORE dependents than anything else in ThoughtSpot - # so we'll trust most of the fields coming back from the ThoughtSpot API and only validate the - # complex_types. - { - "cluster_guid": cluster, - "dependent_guid": dependent["id"], - "column_guid": result["metadata_id"], - "name": dependent["name"], - "description": dependent.get("description", None), - "author_guid": dependent["author"], - "created": validators.ensure_datetime_is_utc.func(dependent["created"] / 1000), - "modified": validators.ensure_datetime_is_utc.func(dependent["modified"] / 1000), - "object_type": dependent_type, - "object_subtype": dependent.get("type", None), - "is_verified": dependent.get("isVerified", False), - "is_version_controlled": dependent.get("isVersioningEnabled", False), - } - ) + yield column_guid, dependent_type, dependent + + +def _reshape_dependent( + dependent: dict[str, Any], *, dependent_type: str, column_guid: _types.GUID, cluster: _types.GUID +) -> dict[str, Any]: + """ + Reshape one dependent into a DependentObject row. + + DEV NOTE: @boonhapus, 2024/12/06 — there are typically an order of magnitude MORE dependents + than anything else, so we trust most fields as-is. Timestamps are tolerated as absent (some + object types, e.g. FEEDBACK, omit 'created') via validators.utc_from_millis -> stored NULL. + """ + return { + "cluster_guid": cluster, + "dependent_guid": dependent["id"], + "column_guid": column_guid, + "name": dependent["name"], + "description": dependent.get("description", None), + "author_guid": dependent["author"], + "created": validators.utc_from_millis(dependent.get("created")), + "modified": validators.utc_from_millis(dependent.get("modified")), + "object_type": dependent_type, + "object_subtype": dependent.get("type", None), + "is_verified": dependent.get("isVerified", False), + "is_version_controlled": dependent.get("isVersioningEnabled", False), + } - return reshaped + +def ts_metadata_dependent(data: list[_types.APIResult], *, cluster: _types.GUID) -> _types.TableRowsFormat: + """Reshape metadata/search LOGICAL_COLUMN dependents -> DependentObject rows.""" + return [ + _reshape_dependent(dependent, dependent_type=dependent_type, column_guid=column_guid, cluster=cluster) + for column_guid, dependent_type, dependent in _iter_dependents(data) + ] def ts_metadata_permissions( diff --git a/cs_tools/cli/tools/searchable/app.py b/cs_tools/cli/tools/searchable/app.py index 85ce0a42..4e6d68bb 100644 --- a/cs_tools/cli/tools/searchable/app.py +++ b/cs_tools/cli/tools/searchable/app.py @@ -17,6 +17,7 @@ from cs_tools import _types, utils from cs_tools.api import workflows from cs_tools.cli import ( + _logging, custom_types, progress as px, ) @@ -31,6 +32,87 @@ app = AsyncTyper(help="""Explore your ThoughtSpot metadata, in ThoughtSpot!""") +def _warn_incomplete_extract( + failures: list[workflows.metadata.FetchFailure], + *, + load_strategy: str | None, + load_was_skipped: bool, + names: dict[str, str] | None = None, +) -> None: + """ + Report metadata gaps prominently. + + A partial phase still completes, so its progress line shows a checkmark. Without a distinct + block here, an incomplete extract is indistinguishable from a complete one at a glance. + + The earlier phases already fetched every object's name, so when the caller passes their + guid -> name lookup, the examples name the affected objects instead of showing bare guids. + """ + by_kind: dict[tuple[str, str], list[str]] = collections.defaultdict(list) + + for failure in failures: + by_kind[(failure.fetched, failure.metadata_type)].extend(failure.identifiers) + + def display(identifier: str) -> str: + return (names or {}).get(identifier, identifier) + + affected = "\n".join( + f" [fg-secondary]{f'{fetched} of ' if fetched != 'data' else ''}{metadata_type}[/]: " + f"{len(identifiers):,} object(s), eg. {', '.join(display(_) for _ in identifiers[:3])}" + + ("" if len(identifiers) <= 3 else f" (+{len(identifiers) - 3:,} more)") + for (fetched, metadata_type), identifiers in by_kind.items() + ) + + # "WHERE IS THE LOG FILE?" IS THE FIRST QUESTION ON NEARLY EVERY SUPPORT TICKET -- + # ANSWER IT WITH THIS RUN'S EXACT PATH. OMITTED WHEN NO FILE HANDLER IS CONFIGURED. + logfile = _logging.active_logfile() + where_to_look = ( + f"\n\nThe complete list of affected objects and full error details are in this run's logfile:\n{logfile}" + if logfile + else "" + ) + + if load_was_skipped: + outcome = ( + "This run's TRUNCATE load strategy would have replaced your target's existing data with " + "this incomplete extract, so the load was skipped. Your target was NOT modified. " + "Re-run to try again." + ) + elif load_strategy is not None: + outcome = ( + f"The incomplete data was written with this run's {load_strategy} load strategy, which " + f"keeps existing data. A later run will fill in the gaps." + ) + else: + # FILE SYNCERS (csv, excel, ...) REWRITE THEIR OUTPUT WHOLESALE EVERY RUN -- NOTHING MERGES. + outcome = "The incomplete data has been written. Re-run to produce a complete extract." + + log.warning( + f"\n[fg-warn]{'━' * 76}[/]" + f"\n[fg-error]INCOMPLETE EXTRACT[/] -- some metadata could not be fetched." + f"\n" + f"\n{len(failures):,} API request(s) failed after retries, affecting:" + f"\n{affected}" + f"\n" + f"\n{outcome}" + f"{where_to_look}" + f"\n[fg-warn]{'━' * 76}[/]" + ) + + # THE CONSOLE BLOCK ABOVE SAMPLES 3 EXAMPLES PER GROUP. SUPPORT WORKS FROM THE LOGFILE, + # SO WRITE THE COMPLETE LIST THERE -- DEBUG REACHES ONLY THE FILE HANDLER (CONSOLE IS INFO+). + def labelled(identifier: str) -> str: + name = (names or {}).get(identifier) + return f" {identifier} {name}" if name else f" {identifier}" + + manifest = "\n".join( + f"{f'{fetched} of ' if fetched != 'data' else ''}{metadata_type} ({len(identifiers):,}):\n" + + "\n".join(labelled(_) for _ in identifiers) + for (fetched, metadata_type), identifiers in by_kind.items() + ) + log.debug(f"INCOMPLETE EXTRACT manifest -- every affected object:\n{manifest}") + + def _ensure_external_mapping(tml: _types.TML, *, connection_info: dict[str, str]) -> _types.TML: """Remap TML object to match the external database.""" if not isinstance(tml, Table): @@ -61,6 +143,19 @@ def _is_in_current_org(metadata_object, *, current_org: int) -> bool: return current_org in (metadata_object["metadata_header"].get("orgIds", None) or [0]) +def _report_tml_import_results(tmls: list[_types.TML], results: list[_types.APIResult]) -> None: + """ + Log the import status for each submitted TML. + + DEV NOTE: the import response is positionally 1:1 with the TMLs we submitted, in order. Do NOT + use a result's `request_index` as an index into `tmls` -- it is a server-side ordinal that can + exceed the number of submitted TMLs, so `tmls[request_index]` raises IndexError. + """ + for tml, result in zip(tmls, results): + if result["response"]["status"]["status_code"] == "OK": + log.info(f"{tml.tml_type_name.upper()} '{tml.name}' successfully imported") + + @app.command() @depends_on(thoughtspot=ThoughtSpot()) def deploy( @@ -175,13 +270,7 @@ def deploy( log.error(f"Failed to call metadata/tml/import.. {e}") return 1 - for tml_import_info in d: - idx = tml_import_info["request_index"] - tml = tmls[idx] - tml_type = tml.tml_type_name.upper() - - if tml_import_info["response"]["status"]["status_code"] == "OK": - log.info(f"{tml_type} '{tml.name}' successfully imported") + _report_tml_import_results(tmls, d) return 0 @@ -433,6 +522,17 @@ def metadata( else: collect_info = False + # ANY FETCH CAN FAIL AFTER RETRIES (DEPENDENT LISTS IN PARTICULAR ARE UNBOUNDED AND + # CANNOT BE PAGINATED, SO THOSE REQUESTS CAN ALWAYS TIME OUT; PERMISSIONS ARE THE MOST + # SERVER-EXPENSIVE DATA WE ASK FOR). COLLECT THE GAPS ACROSS EVERY PHASE AND ORG, AND + # REPORT THEM ONCE, AT THE END. + fetch_failures: list[workflows.metadata.FetchFailure] = [] + + # NAMES FOR THE END-OF-RUN GAP REPORT -- THE INVENTORY AND COLUMN PHASES SEE EVERY + # OBJECT'S NAME BEFORE THE FAILURE-PRONE PHASES RUN, SO GAPS CAN BE NAMED, NOT JUST + # COUNTED AS GUIDS. + guid_names: dict[str, str] = {} + for org in orgs: tracker.title = f"Fetching Data in [fg-secondary]{org['name']}[/] (Org {org['id']})" seen_guids: dict[_types.APIObjectType, set[_types.GUID]] = collections.defaultdict(set) @@ -520,6 +620,7 @@ def metadata( for metadata in _: if _is_in_current_org(metadata, current_org=org["id"]): seen_guids[metadata["metadata_type"]].add(metadata["metadata_id"]) + guid_names[metadata["metadata_id"]] = f"'{metadata['metadata_name']}'" # DUMP DATA_SOURCE DATA d = api_transformer.ts_data_source(data=_, cluster=CLUSTER_UUID) @@ -537,7 +638,11 @@ def metadata( # USE include_hidden_objects=True BECAUSE HIDDEN COLUMNS ON A LOGICAL_TABLE AREN'T RETURNED WITHOUT IT. g = {"LOGICAL_TABLE": seen_guids["LOGICAL_TABLE"]} c = workflows.metadata.fetch( - typed_guids=g, include_details=True, include_hidden_objects=True, http=ts.api + typed_guids=g, + include_details=True, + include_hidden_objects=True, + failures=fetch_failures, + http=ts.api, ) _ = utils.run_sync(c) @@ -553,6 +658,10 @@ def metadata( ) continue + table_name = metadata["metadata_header"]["name"] + for column in metadata["metadata_detail"]["columns"]: + guid_names[column["header"]["id"]] = f"'{column['header']['name']}' ({table_name})" + seen_columns.append([_["header"]["id"] for _ in metadata["metadata_detail"]["columns"]]) # DUMP METDATA_OBJECT DATA (UPSERT LOGICAL_TABLE with .data_source_guid) @@ -572,6 +681,7 @@ def metadata( typed_guids={"LOGICAL_COLUMN": seen_columns}, include_dependent_objects=True, dependent_objects_record_size=-1, + failures=fetch_failures, http=ts.api, ) _ = utils.run_sync(c) @@ -589,7 +699,10 @@ def metadata( seen_guids["LOGICAL_COLUMN"] = seen_columns c = workflows.metadata.permissions( - typed_guids=seen_guids, compat_ts_version=COMPAT_TS_VERSION, http=ts.api + typed_guids=seen_guids, + compat_ts_version=COMPAT_TS_VERSION, + failures=fetch_failures, + http=ts.api, ) _ = utils.run_sync(c) @@ -607,10 +720,18 @@ def metadata( tracker["ORGS_COUNT"].stop() + is_truncate_load_strategy = isinstance(syncer, DatabaseSyncer) and syncer.load_strategy == "TRUNCATE" + + # A TRUNCATE LOAD REPLACES THE TARGET WHOLESALE. REFUSE TO SWAP COMPLETE DATA FOR AN + # INCOMPLETE EXTRACT -- LEAVE WHAT THE CUSTOMER ALREADY HAS AND LET THEM RE-RUN. APPEND + # AND UPSERT ARE ADDITIVE, SO THEY PROCEED AND A LATER RUN FILLS IN THE GAPS. + if fetch_failures and is_truncate_load_strategy: + tracker["DUMP_DATA"].skip(reason="incomplete extract") + _warn_incomplete_extract(fetch_failures, load_strategy="TRUNCATE", load_was_skipped=True, names=guid_names) + return 1 + with tracker["DUMP_DATA"]: # WRITE ALL THE COMBINED DATA TO THE TARGET SYNCER - is_truncate_load_strategy = isinstance(syncer, DatabaseSyncer) and syncer.load_strategy == "TRUNCATE" - for model in models.METADATA_MODELS: streamer = temp.read_stream(tablename=model.__tablename__, batch=1_000_000) @@ -620,6 +741,11 @@ def metadata( syncer.dump(model.__tablename__, data=rows) + if fetch_failures: + strategy = syncer.load_strategy if isinstance(syncer, DatabaseSyncer) else None + _warn_incomplete_extract(fetch_failures, load_strategy=strategy, load_was_skipped=False, names=guid_names) + return 1 + return 0 diff --git a/cs_tools/cli/tools/searchable/models.py b/cs_tools/cli/tools/searchable/models.py index bf24d9fa..4108cdd1 100644 --- a/cs_tools/cli/tools/searchable/models.py +++ b/cs_tools/cli/tools/searchable/models.py @@ -53,8 +53,8 @@ class User(ValidatedSQLModel, table=True): email: Optional[str] display_name: str sharing_visibility: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) user_type: str user_author_guid: str @@ -73,8 +73,8 @@ class Group(ValidatedSQLModel, table=True): description: Optional[str] = Field(sa_column=Column(Text, info={"length_override": "MAX"})) display_name: str sharing_visibility: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) group_type: str @pydantic.field_validator("description", mode="before") @@ -118,8 +118,8 @@ class Tag(ValidatedSQLModel, table=True): tag_guid: str = Field(primary_key=True) tag_name: str author_guid: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) color: Optional[str] @pydantic.field_validator("created", "modified", mode="before") @@ -137,8 +137,8 @@ class DataSource(ValidatedSQLModel, table=True): name: str description: Optional[str] author: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) @pydantic.field_validator("description", mode="before") @classmethod @@ -161,8 +161,8 @@ class MetadataObject(ValidatedSQLModel, table=True): name: str = Field(sa_column=Column(Text, info={"length_override": "MAX"})) description: Optional[str] = Field(sa_column=Column(Text, info={"length_override": "MAX"})) author_guid: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) object_type: str object_subtype: Optional[str] data_source_guid: Optional[str] @@ -221,13 +221,15 @@ def clamp_1_to_10(cls, value: int, info: pydantic.ValidationInfo) -> int: if 1 <= value <= 10: return value + coerced = max(1, min(10, value)) + log.warning( - f"INDEX_PRIORITY is clamped between 1 and 10 in ThoughtSpot, though no validation occurs in the UI. The " - f"column '{info.data['column_name']}' has the value of {int(value):,}. " + f"INDEX_PRIORITY only supports values from 1 to 10, but the ThoughtSpot UI does not enforce this. The " + f"column '{info.data['column_name']}' is set to {int(value):,} and will be recorded as {coerced}. " f"[COLUMN {info.data['column_guid']} IN TABLE {info.data['object_guid']}]" ) - return max(1, min(10, value)) + return coerced class ColumnSynonym(ValidatedSQLModel, table=True, frozen=True): @@ -270,8 +272,8 @@ class DependentObject(ValidatedSQLModel, table=True): name: str = Field(sa_column=Column(Text, info={"length_override": "MAX"})) description: Optional[str] = Field(sa_column=Column(Text, info={"length_override": "MAX"})) author_guid: str - created: dt.datetime = Field(sa_column=Column(TIMESTAMP)) - modified: dt.datetime = Field(sa_column=Column(TIMESTAMP)) + created: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) + modified: Optional[dt.datetime] = Field(None, sa_column=Column(TIMESTAMP)) object_type: str object_subtype: Optional[str] is_verified: Optional[bool] @@ -286,7 +288,7 @@ def remove_leading_trailing_spaces(cls, value: Any) -> Optional[str]: @pydantic.field_validator("created", "modified", mode="before") @classmethod - def check_valid_utc_datetime(cls, value: Any) -> dt.datetime: + def check_valid_utc_datetime(cls, value: Any) -> Optional[dt.datetime]: return validators.ensure_datetime_is_utc.func(value) diff --git a/cs_tools/sync/snowflake/MANIFEST.json b/cs_tools/sync/snowflake/MANIFEST.json index a164bd30..9fd97022 100644 --- a/cs_tools/sync/snowflake/MANIFEST.json +++ b/cs_tools/sync/snowflake/MANIFEST.json @@ -2,6 +2,6 @@ "name": "snowflake", "syncer_class": "Snowflake", "requirements": [ - "snowflake-sqlalchemy >= 1.6.1" + "snowflake-sqlalchemy >= 1.7.4" ] } diff --git a/cs_tools/updater/_bootstrapper.py b/cs_tools/updater/_bootstrapper.py index 4b7b44ca..65481dbf 100644 --- a/cs_tools/updater/_bootstrapper.py +++ b/cs_tools/updater/_bootstrapper.py @@ -21,7 +21,7 @@ import typing _LOG = logging.getLogger("cs_tools.bootstrapper") -__version__ = "1.0.3" +__version__ = "1.0.4" __minimum_python_version__ = (3, 9) @@ -535,14 +535,10 @@ def ensure_import_cs_tools_venv(ref=None): # type: ignore[name-defined] return CSToolsVenv -def http_request(url, to_json=True, timeout=None): - # type: (str, bool, float | None) -> dict[str, typing.Any] | bytes - """Makes a GET request to .""" - import json +def _ssl_context(): + # type: () -> ssl.SSLContext + """The permissive SSL context the installer uses (legacy TLS + corporate-proxy tolerant).""" import ssl - import urllib.request - import urllib.error - import urllib ctx = ssl.create_default_context() @@ -557,11 +553,43 @@ def http_request(url, to_json=True, timeout=None): ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _http_get_with_retries(open_fn, attempts=3, base_delay=1.0): + # type: (typing.Callable[[], typing.Any], int, float) -> typing.Any + """Call open_fn(), retrying transient network errors with exponential backoff.""" + import time + import urllib.error + + for attempt in range(1, attempts + 1): + try: + return open_fn() + except urllib.error.URLError as e: + if attempt == attempts: + raise + delay = base_delay * (2 ** (attempt - 1)) + _LOG.debug("Request failed ({e}); retrying {a}/{t} in {d}s".format(e=e, a=attempt, t=attempts, d=delay)) + time.sleep(delay) + + +def http_request(url, to_json=True, timeout=None): + # type: (str, bool, float | None) -> dict[str, typing.Any] | bytes + """Makes a GET request to .""" + import json + import urllib.request + import urllib.error + + ctx = _ssl_context() + + def _open(): + with urllib.request.urlopen(url, timeout=timeout, context=ctx) as r: + return r.read() + try: _LOG.debug("Making HTTP GET {u}".format(u=url)) - with urllib.request.urlopen(url, timeout=timeout, context=ctx) as r: - data = r.read() + data = _http_get_with_retries(_open) if to_json: data = json.loads(data) @@ -581,14 +609,30 @@ def http_request(url, to_json=True, timeout=None): def get_latest_cs_tools_release(timeout=None): # type: (float | None) -> dict[str, typing.Any] - """Gets the latest CS Tools release.""" - base = "https://api.github.com/repos/{owner}/{repo}/releases/latest" - endp = base.format(owner="thoughtspot", repo="cs_tools") + """ + Resolve the latest CS Tools release tag. - data = http_request(endp, timeout=timeout) - assert isinstance(data, dict), "http_request didn't return valid JSON, got '{!r}".format(data) + Uses the github.com web redirect (/releases/latest -> /releases/tag/) rather than + api.github.com, whose 60 requests/hour unauthenticated per-IP limit shared CI / corporate + egress IPs exhaust (HTTP 403). github.com web routes are not subject to that quota, so this + needs no token and works the same for CI runners and end-user one-liner installs. + """ + import urllib.request - return data + ctx = _ssl_context() + url = "https://github.com/{owner}/{repo}/releases/latest".format(owner="thoughtspot", repo="cs_tools") + + def _resolve(): + # urlopen follows the 302; geturl() is the resolved /releases/tag/ URL. + with urllib.request.urlopen(url, timeout=timeout, context=ctx) as r: + return r.geturl() + + final_url = _http_get_with_retries(_resolve) + tag = final_url.rstrip("/").rsplit("/", 1)[-1] + assert tag and tag != "latest", "Could not resolve the latest release tag from {u!r}".format(u=final_url) + + # published_at isn't available from the redirect (that needs the API); callers treat it as optional. + return {"tag_name": tag, "published_at": None} # ====================================================================================================================== diff --git a/cs_tools/validators.py b/cs_tools/validators.py index fe7269c6..de435aae 100644 --- a/cs_tools/validators.py +++ b/cs_tools/validators.py @@ -20,8 +20,13 @@ @pydantic.PlainValidator -def ensure_datetime_is_utc(value: Any) -> pydantic.AwareDatetime: - """Ensures the input value is a valid, aware, datetime.""" +def ensure_datetime_is_utc(value: Any) -> pydantic.AwareDatetime | None: + """Ensures the input value is a valid, aware, datetime (or None if absent).""" + # NULL CASE: ThoughtSpot APIs omit created/modified for some object types. A batch extract + # stores null rather than crashing; a genuinely-required field stays loud via its own typing. + if value is None: + return None + # HAPPIEST CASE if (value_is_a_datetime := isinstance(value, dt.datetime)) and value.tzinfo == dt.timezone.utc: return value @@ -63,6 +68,18 @@ def ensure_datetime_is_utc(value: Any) -> pydantic.AwareDatetime: assert 1 == 0, "This should be unreachable." +def utc_from_millis(value: Any) -> dt.datetime | None: + """ + Epoch-milliseconds -> aware UTC datetime, or None when the field is absent/null. + + Some object types omit created/modified; a batch extract stores null rather than crashing. + Guards the `/ 1000` against a missing (KeyError) or null (TypeError) value -- pass `src.get(...)`. + """ + if value is None: + return None + return ensure_datetime_is_utc.func(value / 1000) + + @pydantic.PlainValidator def ensure_valid_uuid4(value: Any) -> str: """Ensures the input value is a valid UUID4, in hex-string format.""" diff --git a/tests/test_entrypoint_exit_code.py b/tests/test_entrypoint_exit_code.py new file mode 100644 index 00000000..47892ca4 --- /dev/null +++ b/tests/test_entrypoint_exit_code.py @@ -0,0 +1,30 @@ +""" +Behavioral spec for the CLI's process exit code. + +CI pipelines gate on cs_tools' exit code (see docs/guides/process-vcs.md). The console-script +exe wraps the entrypoint in sys.exit(run()), but `python -m cs_tools` executes __main__.py -- +which once called run() WITHOUT sys.exit, discarding every command's return code and exiting 0. +A pipeline invoking cs_tools that way treats every failure as success. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + + +def test_python_dash_m_propagates_the_command_exit_code(tmp_path): + # A MISSING CONFIG IS A DETERMINISTIC, NO-NETWORK FAILURE: CSToolsError -> run() RETURNS 1. + # POINTING THE CS TOOLS HOME AT tmp_path GUARANTEES THE CONFIG IS MISSING AND KEEPS THE + # RUN'S LOGFILE OUT OF THE REAL USER DIRECTORY (WINDOWS READS APPDATA, LINUX XDG). + env = {**os.environ, "APPDATA": str(tmp_path), "XDG_CONFIG_HOME": str(tmp_path)} + + proc = subprocess.run( + [sys.executable, "-m", "cs_tools", "config", "check", "--config", "definitely-not-a-real-config"], + env=env, + capture_output=True, + timeout=60, + ) + + assert proc.returncode == 1, proc.stdout diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 00000000..2d110357 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,109 @@ +""" +Spec for WorkTask phase logging. + +Every phase (a `with tracker[...]:` block) emits a start line and a timed end line, so a run +narrates itself to the console and the persistent log — no more black-box phases. +""" + +from __future__ import annotations + +import io +import logging + +from cs_tools.cli.progress import WorkTask, WorkTracker +from cs_tools.cli.ux import CS_TOOLS_THEME +from rich.console import Console + + +def test_phase_logs_start_and_timed_end(caplog): + with caplog.at_level(logging.INFO): + with WorkTask(id="TS_ORG", description=" Fetching [fg-secondary]ORG[/] data"): + pass + + # rich markup and indentation are stripped; both start and timed-end are logged + assert "→ Fetching ORG data" in caplog.text + assert "✓ Fetching ORG data (" in caplog.text + + +def test_reentry_logs_per_entry_duration_not_cumulative(caplog): + # The metadata command re-enters the same task once per org. Each entry must log ITS OWN + # duration, not the accumulated total (which is what self.elapsed carries). + task = WorkTask(id="TS_ORG", description="Fetching ORG data") + clock = iter([0.0, 10.0, 100.0, 105.0, 200.0, 300.0]) # entry1: 0->10 (10s); entry2: 100->105 (5s) + task.get_time = lambda: next(clock) + + with caplog.at_level(logging.INFO): + with task: + pass + with task: + pass + + assert "(10.0s)" in caplog.text # first entry + assert "(5.0s)" in caplog.text # second entry, per-entry + assert "(15.0s)" not in caplog.text # NOT the cumulative 10 + 5 + + +def test_phase_logs_failure_on_exception(caplog): + with caplog.at_level(logging.INFO): + try: + with WorkTask(id="TS_ORG", description="Fetching ORG data"): + raise ValueError("boom") + except ValueError: + pass + + assert "✗ Fetching ORG data failed after" in caplog.text + assert "✓ Fetching ORG data" not in caplog.text + + +def _render_bar_cell(task: WorkTask) -> str: + console = Console(width=120, file=io.StringIO(), theme=CS_TOOLS_THEME) + console.print(task.__render__(text_width=40, bar_width=60)) + return console.file.getvalue() # type: ignore[attr-defined] + + +def test_a_skipped_task_says_so_instead_of_dashes(caplog): + # A DELIBERATELY SKIPPED PHASE (eg. THE TRUNCATE GUARD REFUSING AN INCOMPLETE LOAD) USED TO + # RENDER "--", IDENTICAL TO A PHASE THAT NEVER RAN -- THE FINAL FRAME COULDN'T TELL THE USER + # THAT DATA WAS NOT WRITTEN. + task = WorkTask(id="DUMP_DATA", description="Sending data to snowflake") + + with caplog.at_level(logging.INFO): + task.skip(reason="incomplete extract") + + rendered = _render_bar_cell(task) + assert "skipped" in rendered + assert "incomplete extract" in rendered + + # THE LOGFILE TELLS THE SAME STORY AS THE LIVE TABLE. + assert "⤼ Sending data to snowflake skipped (incomplete extract)" in caplog.text + + +def test_a_never_started_task_still_renders_dashes(): + # "--" NOW MEANS EXACTLY ONE THING: THE PHASE WAS NEVER REACHED. + task = WorkTask(id="DUMP_DATA", description="Sending data to snowflake") + + rendered = _render_bar_cell(task) + assert "--" in rendered + assert "skipped" not in rendered + + +def test_worktracker_restores_cursor_on_exit(): + # WorkTracker (rich Live) hides the cursor on enter; it MUST restore it on exit, or the cursor + # stays hidden after the command finishes -- invisible on lenient terminals, but it persists in + # strict emulators like cloud shells. + console = Console(force_terminal=True, theme=CS_TOOLS_THEME, file=io.StringIO()) + calls: list[bool] = [] + real_show_cursor = console.show_cursor + + def spy(show: bool = True) -> bool: + calls.append(show) + return real_show_cursor(show) + + console.show_cursor = spy # type: ignore[method-assign] + + with WorkTracker("deploy", tasks=[WorkTask(id="A", description="Fetching X")], console=console): + pass + + # hidden on enter (False), restored on exit (True) -- the LAST toggle must be a restore + assert calls, "cursor visibility was never toggled" + assert calls[-1] is True, f"cursor not restored on exit; show_cursor calls={calls}" diff --git a/tests/test_scriptability_tml_drift.py b/tests/test_scriptability_tml_drift.py new file mode 100644 index 00000000..f8bab4c2 --- /dev/null +++ b/tests/test_scriptability_tml_drift.py @@ -0,0 +1,198 @@ +""" +Behavioral spec for scriptability's TML spec-drift resilience. + +ThoughtSpot Cloud adds attributes to the TML spec faster than the thoughtspot_tml +library ships spec regenerations (viz_style on TS 26.7, dynamic_title before it -- +thoughtspot_tml issues #40 and #29). The library parses strictly, so a single file +carrying an unknown attribute raises TMLDecodeError. + +Pins the properties customers depend on: + + 1. deploy's file loading collects per-file parse failures instead of letting one + drifted file crash the entire deploy before anything is sent. + + 2. A drift-class failure explains itself: the installed thoughtspot_tml version + and the unrecognized attribute, so the operator knows the fix is a library + update -- not a broken file. + + 3. Failures that are NOT drift (e.g. YAML syntax errors) are still collected per + file, with the library's own diagnostic as the reason. + + 4. checkpoint detects exported files the installed library cannot parse -- the + export itself is raw text and always succeeds, so these are landmines that + only explode at deploy time. They must be flagged at export time. + + 5. Failure reporting is capped on the console. A drift event affects every + object using the new attribute (223 of 2,341 Liveboards in one observed + case) -- the console gets the first few in detail and a count of the rest, + while the logfile always receives every failure. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +import pathlib + +from cs_tools.cli.tools.scriptability.app import ( + _FAILURE_DETAIL_LIMIT, + _find_unparseable_exports, + _load_tml_files, + _log_failures_capped, + _TMLParseFailure, +) +import thoughtspot_tml + +GOOD_TABLE_TML = """ +guid: 11111111-2222-3333-4444-555555555555 +table: + name: MY_TABLE + db: MY_DB + schema: MY_SCHEMA + db_table: MY_TABLE +""" + +# A SERVER-SIDE SPEC ADDITION THE INSTALLED LIBRARY DOES NOT KNOW ABOUT. +DRIFTED_TABLE_TML = GOOD_TABLE_TML + " viz_style: FANCY\n" + +NOT_EVEN_YAML = "table:\n name: [unclosed\n" + + +def _write(directory: pathlib.Path, filename: str, text: str) -> pathlib.Path: + path = directory / filename + path.write_text(text, encoding="utf-8") + return path + + +def test_healthy_files_load_with_no_failures(tmp_path): + paths = [ + _write(tmp_path, "one.table.tml", GOOD_TABLE_TML), + _write(tmp_path, "two.table.tml", GOOD_TABLE_TML), + ] + + loaded, failures = _load_tml_files(paths) + + assert failures == [] + assert [path for path, _ in loaded] == paths + assert all(tml.guid == "11111111-2222-3333-4444-555555555555" for _, tml in loaded) + + +def test_a_drifted_file_is_collected_not_raised(tmp_path): + # ONE FILE WITH A NEWER-THAN-THE-LIBRARY ATTRIBUTE MUST NOT COST THE WHOLE DEPLOY. + good = _write(tmp_path, "good.table.tml", GOOD_TABLE_TML) + bad = _write(tmp_path, "bad.table.tml", DRIFTED_TABLE_TML) + + loaded, failures = _load_tml_files([good, bad]) + + # THE HEALTHY FILE STILL LOADS. + assert [path for path, _ in loaded] == [good] + + # THE DRIFTED FILE IS REPORTED, WITH THE LIBRARY'S EXCEPTION ATTACHED. + assert len(failures) == 1 + assert failures[0].source == str(bad) + assert isinstance(failures[0].error, thoughtspot_tml.exceptions.TMLDecodeError) + + +def test_drift_failures_explain_the_library_version_and_attribute(tmp_path): + # "Unrecognized attribute" ALONE SENDS OPERATORS HUNTING FOR A FILE PROBLEM. + # THE REASON MUST POINT AT THE ACTUAL FIX: THE INSTALLED LIBRARY IS TOO OLD. + bad = _write(tmp_path, "bad.table.tml", DRIFTED_TABLE_TML) + + _, failures = _load_tml_files([bad]) + + reason = failures[0].reason + assert "viz_style" in reason + assert importlib.metadata.version("thoughtspot_tml") in reason + assert "newer than the installed" in reason + + +def test_non_drift_decode_failures_are_still_collected_per_file(tmp_path): + # A BROKEN FILE (HAND-EDIT GONE WRONG) IS COLLECTED LIKE ANY OTHER PARSE FAILURE, + # AND ITS REASON IS THE LIBRARY'S OWN DIAGNOSTIC -- NOT THE DRIFT HINT. + good = _write(tmp_path, "good.table.tml", GOOD_TABLE_TML) + bad = _write(tmp_path, "bad.table.tml", NOT_EVEN_YAML) + + loaded, failures = _load_tml_files([good, bad]) + + assert [path for path, _ in loaded] == [good] + assert len(failures) == 1 + assert failures[0].source == str(bad) + assert "newer than the installed" not in failures[0].reason + assert failures[0].reason == str(failures[0].error) + + +def _export_result(edoc, *, guid: str = "g-1", name: str = "MY_TABLE", type_: str = "table") -> dict: + return {"edoc": edoc, "info": {"id": guid, "name": name, "type": type_, "status": {"status_code": "OK"}}} + + +def test_checkpoint_flags_exports_the_installed_library_cannot_parse(): + # EXPORT WRITES RAW TEXT AND SUCCEEDS EVEN WHEN THE EDOC CARRIES DRIFTED ATTRIBUTES. + # THOSE FILES WILL FAIL EVERY DEPLOY UNTIL THE LIBRARY UPDATES -- SAY SO NOW. + results = [ + _export_result(GOOD_TABLE_TML), + _export_result(DRIFTED_TABLE_TML, guid="g-2", name="DRIFTED_TABLE"), + ] + + landmines = _find_unparseable_exports(results) + + assert len(landmines) == 1 + assert "DRIFTED_TABLE" in landmines[0].source + assert "g-2" in landmines[0].source + assert "viz_style" in landmines[0].reason + + +def test_checkpoint_ignores_exports_that_already_failed(): + # A FAILED EXPORT HAS NO EDOC -- IT IS ALREADY REPORTED AS AN ERROR BY THE EXPORT + # TABLE AND MUST NOT ALSO BE COUNTED AS A PARSE LANDMINE. + results = [ + {"edoc": None, "info": {"id": "g-err", "status": {"status_code": "ERROR"}}}, + _export_result(GOOD_TABLE_TML), + ] + + assert _find_unparseable_exports(results) == [] + + +SCRIPTABILITY_LOGGER = "cs_tools.cli.tools.scriptability.app" + + +def _drift_failure(i: int) -> _TMLParseFailure: + error = thoughtspot_tml.exceptions.TMLDecodeError( + thoughtspot_tml.Table, + exc=TypeError("__init__() got an unexpected keyword argument 'viz_style'"), + document="", + ) + return _TMLParseFailure(source=f"lb-{i}.liveboard.tml", error=error) + + +def test_a_failure_flood_is_capped_on_the_console(caplog): + # 223 DRIFTED LIVEBOARDS MUST NOT BECOME 223 CONSOLE LINES. THE CONSOLE HANDLER IS + # INFO+ AND THE FILE HANDLER IS DEBUG+, SO WARNING/ERROR RECORDS STAND IN FOR CONSOLE + # LINES AND DEBUG RECORDS FOR THE LOGFILE-ONLY REMAINDER. + failures = [_drift_failure(i) for i in range(_FAILURE_DETAIL_LIMIT + 3)] + + with caplog.at_level(logging.DEBUG, logger=SCRIPTABILITY_LOGGER): + _log_failures_capped(failures, level=logging.ERROR, describe=lambda f: f"Could not parse '{f.source}'") + + errors = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(errors) == _FAILURE_DETAIL_LIMIT + 1 + assert "..and 3 more, see the logs for the full list." in errors[-1].getMessage() + + # THE LOGFILE RECEIVES EVERY FAILURE EXACTLY ONCE: THE FIRST FEW VIA THEIR CONSOLE + # RECORDS, THE OVERFLOW VIA DEBUG. + debugs = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert [r.getMessage() for r in debugs] == [ + f"Could not parse '{f.source}'" for f in failures[_FAILURE_DETAIL_LIMIT:] + ] + + +def test_a_handful_of_failures_all_appear_in_detail(caplog): + # CAPPING ONLY MATTERS AT SCALE -- A COUPLE OF FAILURES GET FULL DETAIL AND NO TALLY. + failures = [_drift_failure(i) for i in range(2)] + + with caplog.at_level(logging.DEBUG, logger=SCRIPTABILITY_LOGGER): + _log_failures_capped(failures, level=logging.WARNING, describe=lambda f: f"Exported {f.source}") + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 2 + assert "..and" not in caplog.text + assert [r for r in caplog.records if r.levelno == logging.DEBUG] == [] diff --git a/tests/test_searchable_deploy.py b/tests/test_searchable_deploy.py new file mode 100644 index 00000000..5a3243e3 --- /dev/null +++ b/tests/test_searchable_deploy.py @@ -0,0 +1,47 @@ +""" +Spec for deploy's TML-import result reporting. + +The metadata/tml/import response is positionally 1:1 with the submitted TMLs, but each entry's +`request_index` is a server-side ordinal that can exceed len(tmls) -- so it must NOT be used to +index into tmls. Doing so raised IndexError and crashed deploy after a successful import. +""" + +from __future__ import annotations + +from types import SimpleNamespace +import logging + +from cs_tools.cli.tools.searchable.app import _report_tml_import_results + + +def _tml(name: str, type_name: str = "TABLE") -> SimpleNamespace: + return SimpleNamespace(name=name, tml_type_name=type_name) + + +def _result(status_code: str, request_index: int) -> dict: + return {"request_index": request_index, "response": {"status": {"status_code": status_code}}} + + +def test_reporting_ignores_sparse_request_index_and_does_not_indexerror(caplog): + tmls = [_tml("t0"), _tml("t1"), _tml("t2")] + # request_index values that overrun a 3-element list -- the shape observed from the server + results = [_result("OK", 0), _result("OK", 16), _result("OK", 56)] + + with caplog.at_level(logging.INFO): + _report_tml_import_results(tmls, results) # must not raise IndexError + + # positional pairing: each tml is reported in submission order, regardless of request_index + assert "TABLE 't0' successfully imported" in caplog.text + assert "TABLE 't1' successfully imported" in caplog.text + assert "TABLE 't2' successfully imported" in caplog.text + + +def test_non_ok_status_is_not_reported_as_success(caplog): + tmls = [_tml("good"), _tml("bad")] + results = [_result("OK", 0), _result("ERROR", 9)] + + with caplog.at_level(logging.INFO): + _report_tml_import_results(tmls, results) + + assert "TABLE 'good' successfully imported" in caplog.text + assert "'bad' successfully imported" not in caplog.text diff --git a/tests/test_searchable_incomplete_extract.py b/tests/test_searchable_incomplete_extract.py new file mode 100644 index 00000000..6ee91685 --- /dev/null +++ b/tests/test_searchable_incomplete_extract.py @@ -0,0 +1,135 @@ +""" +Spec for the INCOMPLETE EXTRACT summary block. + +The same LOGICAL_COLUMN objects appear in three different fetch phases (details, dependents, +column access), so a breakdown by object type alone cannot say WHAT was lost. And file syncers +(csv, excel, ...) rewrite their output wholesale on every run -- "a later run will fill in the +gaps" only describes database load strategies. +""" + +from __future__ import annotations + +import logging + +from cs_tools.api.workflows.metadata import FetchFailure +from cs_tools.cli._logging import LimitedFileHistoryHandler +from cs_tools.cli.tools.searchable.app import _warn_incomplete_extract +import httpx + +ANY_ERROR = httpx.ReadTimeout("simulated slow endpoint") + + +def _failure(fetched: str, metadata_type: str, *guids: str) -> FetchFailure: + return FetchFailure(metadata_type=metadata_type, identifiers=guids, error=ANY_ERROR, fetched=fetched) + + +def test_breakdown_names_the_kind_of_fetch_per_line(caplog): + failures = [ + _failure("dependents", "LOGICAL_COLUMN", "col-1", "col-2"), + _failure("permissions", "LOGICAL_TABLE", "tbl-1"), + ] + + with caplog.at_level(logging.WARNING): + _warn_incomplete_extract(failures, load_strategy="UPSERT", load_was_skipped=False) + + assert "dependents of LOGICAL_COLUMN" in caplog.text + assert "permissions of LOGICAL_TABLE" in caplog.text + + +def test_file_syncers_are_told_to_rerun_not_promised_a_merge(caplog): + # FILE SYNCERS HAVE NO LOAD STRATEGY -- EVERY RUN REWRITES THE FILES WHOLESALE, SO + # NOTHING EVER "FILLS IN" GAPS. + failures = [_failure("dependents", "LOGICAL_COLUMN", "col-1")] + + with caplog.at_level(logging.WARNING): + _warn_incomplete_extract(failures, load_strategy=None, load_was_skipped=False) + + assert "Re-run to produce a complete extract." in caplog.text + assert "fill in the gaps" not in caplog.text + + +def test_logfile_manifest_enumerates_every_affected_object(caplog): + # THE CONSOLE BLOCK SAMPLES 3 EXAMPLES PER GROUP ("+N more") -- SUPPORT WORKS FROM THE + # LOGFILE AND NEEDS THE COMPLETE LIST. A DEBUG RECORD (FILE HANDLER ONLY; CONSOLE IS + # INFO+) WRITES EVERY IDENTIFIER WITH ITS NAME, GROUPED LIKE THE CONSOLE BLOCK. + failures = [ + _failure("dependents", "LOGICAL_COLUMN", "col-1", "col-2", "col-3", "col-4", "col-5"), + _failure("permissions", "ANSWER", "ans-1"), + ] + names = {"col-1": "'Revenue' (Sales Fact)", "ans-1": "'Copy of Renewals'"} + + with caplog.at_level(logging.DEBUG): + _warn_incomplete_extract(failures, load_strategy="TRUNCATE", load_was_skipped=True, names=names) + + debugs = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG] + manifest = next(m for m in debugs if "INCOMPLETE EXTRACT manifest" in m) + + # EVERY IDENTIFIER APPEARS -- INCLUDING THE ONES BEYOND THE CONSOLE'S 3-EXAMPLE CAP. + for identifier in ("col-1", "col-2", "col-3", "col-4", "col-5", "ans-1"): + assert identifier in manifest + + # GUID FIRST FOR MACHINE JOINS, NAME ALONGSIDE FOR HUMANS; UNRESOLVED GUIDS STAND ALONE. + assert "col-1 'Revenue' (Sales Fact)" in manifest + assert "ans-1 'Copy of Renewals'" in manifest + + # GROUPED ONE-TO-ONE WITH THE CONSOLE BLOCK'S HEADINGS. + assert "dependents of LOGICAL_COLUMN (5):" in manifest + assert "permissions of ANSWER (1):" in manifest + + +def test_manifest_stays_out_of_the_console(caplog): + # THE MANIFEST MUST NOT UN-SOLVE THE CONSOLE FLOOD -- IT RIDES AT DEBUG, BELOW THE + # CONSOLE HANDLER'S INFO THRESHOLD. WARNING-LEVEL OUTPUT IS UNCHANGED. + failures = [_failure("dependents", "LOGICAL_COLUMN", "col-1", "col-2")] + + with caplog.at_level(logging.DEBUG): + _warn_incomplete_extract(failures, load_strategy="TRUNCATE", load_was_skipped=True) + + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warnings) == 1 + assert "manifest" not in warnings[0] + + +def test_block_points_at_the_logfile_when_file_logging_is_active(caplog, tmp_path): + # "WHERE IS THE LOG FILE?" IS THE FIRST SUPPORT QUESTION ON NEARLY EVERY TICKET -- THE + # BLOCK ANSWERS IT WITH THIS RUN'S EXACT PATH, READ FROM THE LIVE FILE HANDLER. + logfile = tmp_path / "2026-01-01T00_00_00.log" + handler = LimitedFileHistoryHandler(max_files_to_keep=25, filename=str(logfile), delay=True) + logging.getLogger().addHandler(handler) + + try: + with caplog.at_level(logging.WARNING): + _warn_incomplete_extract( + [_failure("dependents", "LOGICAL_COLUMN", "col-1")], load_strategy="TRUNCATE", load_was_skipped=True + ) + finally: + logging.getLogger().removeHandler(handler) + handler.close() + + assert "logfile" in caplog.text + assert str(logfile) in caplog.text + + +def test_block_omits_the_logfile_pointer_when_no_file_handler_exists(caplog): + # EMBEDDED / TEST CONTEXTS HAVE NO FILE HANDLER -- THE BLOCK MUST NOT PROMISE A FILE + # THAT DOES NOT EXIST. + with caplog.at_level(logging.WARNING): + _warn_incomplete_extract( + [_failure("dependents", "LOGICAL_COLUMN", "col-1")], load_strategy="TRUNCATE", load_was_skipped=True + ) + + assert "logfile:" not in caplog.text + + +def test_examples_show_names_when_the_run_knows_them(caplog): + # A BARE GUID SENDS THE ADMIN HUNTING; THE RUN ALREADY FETCHED EVERY OBJECT'S NAME + # BEFORE THE FAILING PHASES, SO THE EXAMPLES CAN SAY WHICH OBJECTS WERE AFFECTED. + failures = [_failure("dependents", "LOGICAL_COLUMN", "col-1", "col-2")] + names = {"col-1": "'Revenue' (Sales Fact)"} + + with caplog.at_level(logging.WARNING): + _warn_incomplete_extract(failures, load_strategy="UPSERT", load_was_skipped=False, names=names) + + assert "'Revenue' (Sales Fact)" in caplog.text + # AN IDENTIFIER THE RUN NEVER RESOLVED FALLS BACK TO ITS GUID. + assert "col-2" in caplog.text diff --git a/tests/test_searchable_transformer.py b/tests/test_searchable_transformer.py new file mode 100644 index 00000000..31894a55 --- /dev/null +++ b/tests/test_searchable_transformer.py @@ -0,0 +1,127 @@ +""" +Spec for searchable transformer resilience to omitted timestamps. + +ThoughtSpot APIs omit created/modified for some object types (observed: DependentObject 'created' +for FEEDBACK, Group 'modified' for LOCAL_GROUP). cs_tools is a batch extract -- it must store NULL +and keep the row, never crash. Shared coercion lives in validators.utc_from_millis / +validators.ensure_datetime_is_utc (now None-tolerant). +""" + +from __future__ import annotations + +import datetime as dt +import logging + +from cs_tools import validators +from cs_tools.cli.tools.searchable import api_transformer as T +from cs_tools.cli.tools.searchable.models import DependentObject + +# --- shared coercion -------------------------------------------------------- + + +def test_utc_from_millis_none_is_none(): + assert validators.utc_from_millis(None) is None + + +def test_utc_from_millis_coerces_epoch_millis(): + got = validators.utc_from_millis(1_700_000_000_000) + assert got == dt.datetime(2023, 11, 14, 22, 13, 20, tzinfo=dt.timezone.utc) + + +def test_ensure_datetime_is_utc_tolerates_none(): + # the shared PlainValidator now short-circuits None so every model's validator inherits it + assert validators.ensure_datetime_is_utc.func(None) is None + + +# --- dependents: 'created' omitted for FEEDBACK ----------------------------- + + +def _dep_payload(dependents: list[dict], *, dependent_type: str = "ANSWER") -> dict: + return {"metadata_id": "col-1", "dependent_objects": {"grp": {dependent_type: dependents}}} + + +def _dependent(**overrides) -> dict: + base = {"id": "dep-1", "name": "A", "author": "user-1", "created": 1_700_000_000_000, "modified": 1_700_000_000_000} + base.update(overrides) + return base + + +def test_dependent_missing_created_is_kept_with_null(): + good = _dependent(id="dep-good") + no_created = {k: v for k, v in _dependent(id="dep-feedback").items() if k != "created"} + + rows = T.ts_metadata_dependent([_dep_payload([good, no_created], dependent_type="FEEDBACK")], cluster="cluster-1") + by_guid = {r["dependent_guid"]: r for r in rows} + + assert set(by_guid) == {"dep-good", "dep-feedback"} + assert by_guid["dep-feedback"]["created"] is None + assert by_guid["dep-good"]["created"] is not None + + +# --- groups (v1): 'modified' omitted for LOCAL_GROUP ------------------------ + + +def _group_v1(**header_overrides) -> dict: + header = { + "id": "grp-1", + "name": "G", + "displayName": "G", + "orgIds": [0], + "created": 1_700_000_000_000, + "modified": 1_700_000_000_000, + } + header.update(header_overrides) + return {"header": header, "visibility": "SHARABLE", "type": "LOCAL_GROUP"} + + +def test_group_v1_missing_modified_is_kept_with_null(): + g = _group_v1(id="grp-local") + del g["header"]["modified"] # some LOCAL_GROUP records omit 'modified' + + rows = T.to_group_v1([g], cluster="cluster-1") + + assert len(rows) == 1 + assert rows[0]["group_guid"] == "grp-local" + assert rows[0]["modified"] is None + assert rows[0]["created"] is not None + + +# --- models accept null timestamps ------------------------------------------ + + +def test_model_accepts_null_created_and_modified(): + obj = DependentObject( + cluster_guid="cluster-1", + dependent_guid="dep-1", + column_guid="col-1", + name="A", + description=None, + author_guid="user-1", + created=None, + modified=None, + object_type="FEEDBACK", + object_subtype=None, + is_verified=False, + is_version_controlled=False, + ) + assert obj.created is None + assert obj.modified is None + + +# --- column synonyms: duplicate warnings name the parent table --------------- + + +def test_duplicate_synonym_warning_names_column_and_parent_table(caplog): + # MATCHES THE [COLUMN x IN TABLE y] SUFFIX USED BY THE INDEX_PRIORITY WARNING -- A COLUMN + # GUID ALONE ISN'T ENOUGH TO FIND THE OFFENDING FIELD IN THE UI. + payload = { + "metadata_id": "tbl-1", + "metadata_detail": {"columns": [{"header": {"id": "col-1"}, "synonyms": ["revenue", "revenue"]}]}, + } + + with caplog.at_level(logging.WARNING): + rows = T.ts_column_synonym([payload], cluster="cluster-1") + + # THE DUPLICATE IS DROPPED, THE FIRST IS KEPT + assert len(rows) == 1 + assert "[COLUMN col-1 IN TABLE tbl-1]" in caplog.text diff --git a/tests/test_workflows_metadata.py b/tests/test_workflows_metadata.py index 25076f02..34bd6397 100644 --- a/tests/test_workflows_metadata.py +++ b/tests/test_workflows_metadata.py @@ -5,7 +5,7 @@ in-memory httpx.MockTransport (via CachedRetryTransport's wrapped_transport seam). No network access occurs. -Pins two properties customers depend on: +Pins the properties customers depend on: 1. fetch bounds the number of identifiers per metadata/search request, re-batching however the caller happened to group them. This keeps a @@ -14,7 +14,14 @@ of requests. 2. A single failing batch does not cancel its siblings. fetch returns the - results it could gather instead of aborting the whole phase. + rows it could gather and reports the batches it could not, instead of + aborting the whole phase. + + 3. Failures that are NOT transport failures still propagate. Tolerating a + dropped request must not silently swallow a bug. + + 4. Console reporting of failures is capped. A failure storm must not bury + the progress display under one ERROR line per failed batch. """ from __future__ import annotations @@ -22,11 +29,12 @@ from typing import Callable, Union import asyncio import json +import logging import math -from cs_tools import _compat from cs_tools.api.client import RESTAPIClient from cs_tools.api.workflows import metadata as metadata_workflow +import awesomeversion import httpx import pytest @@ -75,6 +83,15 @@ async def do_not_sleep(seconds: float) -> None: # noqa: ARG001 return client +def fails_on(marker: bytes, error: Exception) -> Callable[[httpx.Request], Union[int, Exception]]: + """Answer 200, except for requests carrying the marker.""" + + def respond(request: httpx.Request) -> Union[int, Exception]: + return error if marker in request.content else 200 + + return respond + + def test_a_wide_identifier_list_is_split_into_bounded_requests(): # ONE TABLE'S WORTH OF COLUMNS, GROUPED AS THE CALLER DOES IT (a single list), # MUST NOT BECOME ONE ENORMOUS metadata/search. @@ -123,29 +140,336 @@ async def scenario() -> None: assert set(sent) == guids -def test_c_a_failing_batch_aborts_and_raises_the_current_contract(): - # CONTRACT PIN, NOT AN ASPIRATION: today a request that fails at the network - # level (after retries are exhausted) aborts the whole phase and propagates. - # Customers key retries / exit codes off this, so re-batching (fix A) must not - # quietly turn it into a partial-success return. If we ever choose to make the - # phase resilient, that is a deliberate contract change with its own decision. - def respond(request: httpx.Request) -> Union[int, Exception]: - if b"BOOM" in request.content: - return httpx.ReadTimeout("simulated slow endpoint") - return 200 +def test_c_a_failing_batch_does_not_abort_the_phase(): + # DEPENDENTS CANNOT BE PAGINATED, SO A REQUEST CAN ALWAYS EXCEED THE READ TIMEOUT. + # ONE DROPPED BATCH MUST NOT COST THE ENTIRE ~25 MINUTE EXTRACT. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + good = [f"good-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [good, bad]}, + include_dependent_objects=True, + dependent_objects_record_size=-1, + http=client, + ) + + rows = asyncio.run(scenario()) + + # THE SURVIVING BATCH'S ROWS COME BACK RATHER THAN BEING DISCARDED. + assert rows == [{"metadata_id": "OK"}] + + +def test_c_a_failing_batch_does_not_cancel_its_siblings(): + # THE FAIL-FAST TaskGroup USED TO CANCEL IN-FLIGHT *AND* NOT-YET-STARTED BATCHES. + # EVERY HEALTHY BATCH MUST STILL BE ATTEMPTED AND RETURNED. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadError("simulated dropped response"))) + before = [f"before-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + after = [f"after-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [before, bad, after]}, + include_dependent_objects=True, + dependent_objects_record_size=-1, + http=client, + ) + + rows = asyncio.run(scenario()) + + assert len(rows) == 2, "both healthy batches should have survived the failing one" + + attempted = {g for batch in server.sent_identifiers() for g in batch} + assert set(before) <= attempted + assert set(after) <= attempted + + +def test_c_failed_batches_are_reported_to_the_caller(): + # A PARTIAL EXTRACT IS ONLY ACCEPTABLE IF THE CALLER CAN SAY EXACTLY WHAT IS MISSING. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + good = [f"good-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> None: + client = make_client(server) + await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [good, bad]}, + include_dependent_objects=True, + dependent_objects_record_size=-1, + failures=failures, + http=client, + ) + + asyncio.run(scenario()) + + assert len(failures) == 1 + assert failures[0].metadata_type == "LOGICAL_COLUMN" + assert list(failures[0].identifiers) == bad + assert isinstance(failures[0].error, httpx.HTTPError) + + +def test_c_a_healthy_run_reports_no_failures(): + # THE COLLECTOR MUST STAY EMPTY WHEN NOTHING GOES WRONG -- OTHERWISE EVERY RUN + # WOULD TRIP THE PARTIAL-EXTRACT GUARD. + server = RecordingServer(respond=lambda _: 200) + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [[f"col-{i}" for i in range(60)]]}, + failures=failures, + http=client, + ) + + rows = asyncio.run(scenario()) + + assert failures == [] + assert len(rows) == 3 + - server = RecordingServer(respond=respond) +def test_c_an_error_status_is_treated_as_a_failed_batch(): + # A 4xx/5xx THAT SURVIVES THE RETRY POLICY IS A MISSING BATCH, NOT A CRASH. + server = RecordingServer(respond=lambda request: 500 if b"BOOM" in request.content else 200) good = [f"good-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [good, bad]}, + failures=failures, + http=client, + ) + + rows = asyncio.run(scenario()) + + assert rows == [{"metadata_id": "OK"}] + assert len(failures) == 1 + assert list(failures[0].identifiers) == bad + + +def test_c_exhausted_server_pressure_retries_are_a_failed_batch_not_a_crash(): + # A SERVER STUCK RETURNING 429/502/503/504 EXHAUSTS THE RESULT-BASED RETRY POLICY, WHICH + # SURFACES AS tenacity.RetryError (NOT AN httpx ERROR -- reraise=True ONLY COVERS EXCEPTIONS). + # THAT IS STILL "THE SERVER COULD NOT ANSWER", SO IT MUST BECOME A REPORTED GAP, NOT A CRASH. + server = RecordingServer(respond=lambda request: 429 if b"BOOM" in request.content else 200) + good = [f"good-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [good, bad]}, + failures=failures, + http=client, + ) + + rows = asyncio.run(scenario()) + + assert rows == [{"metadata_id": "OK"}] + assert len(failures) == 1 + assert list(failures[0].identifiers) == bad + # THE FAILURE IS RECORDED AS THE UNDERLYING STATUS ERROR, NOT AN OPAQUE RetryError. + assert isinstance(failures[0].error, httpx.HTTPStatusError) + assert failures[0].error.response.status_code == 429 + # THE RETRY POLICY STILL RAN ITS COURSE BEFORE THE BATCH WAS DECLARED FAILED. + assert sum(b"BOOM" in r.content for r in server.requests) == 3 + + +def test_c_a_failure_storm_does_not_flood_the_console(caplog): + # A SERVER STUCK ON PRESSURE STATUSES CAN FAIL THOUSANDS OF BATCHES IN A SINGLE PHASE. + # THE CONSOLE GETS THE FIRST FEW FAILURES IN DETAIL AND A PERIODIC TALLY AFTER THAT -- + # NEVER ONE ERROR LINE PER FAILURE. THE COLLECTOR STILL RECEIVES EVERY FAILURE. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + n_failing_batches = metadata_workflow._FailureDigest.DETAIL_LIMIT + 3 + guids = [f"BOOM-{i}" for i in range(n_failing_batches * EXPECTED_MAX_PER_REQUEST)] + failures: list[metadata_workflow.FetchFailure] = [] async def scenario() -> list: client = make_client(server) return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [guids]}, + failures=failures, + http=client, + ) + + with caplog.at_level(logging.ERROR, logger="cs_tools.api.workflows.metadata"): + asyncio.run(scenario()) + + assert len(failures) == n_failing_batches + + # EVERY BATCH FAILED, BUT ONLY THE FIRST DETAIL_LIMIT GET THEIR OWN ERROR LINE. THE NEXT + # TALLY WOULD ONLY APPEAR AT TALLY_EVERY FAILURES, SO NO OTHER ERROR RECORDS EXIST HERE. + errors = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(errors) == metadata_workflow._FailureDigest.DETAIL_LIMIT + assert all("Could not fetch data for" in r.getMessage() for r in errors) + + +def test_d_non_transport_errors_still_propagate(): + # TOLERATING A DROPPED REQUEST MUST NOT TURN A BUG INTO A SILENT PARTIAL EXTRACT. + server = RecordingServer(respond=lambda _: RuntimeError("a bug, not a flaky network")) + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [["col-0"]]}, + http=client, + ) + + with pytest.raises(RuntimeError): + asyncio.run(scenario()) + + +# ANY VERSION ON THE security/metadata/fetch-permissions (V2) CODE PATH. +ANY_MODERN_TS_VERSION = awesomeversion.AwesomeVersion("10.5.0") + + +def test_e_a_failing_permission_request_does_not_abort_the_phase(): + # PERMISSIONS IS THE MOST SERVER-EXPENSIVE PHASE WE RUN, AND IT COMES LAST -- A SINGLE + # FAILED REQUEST USED TO THROW AWAY EVERY PHASE THAT ALREADY SUCCEEDED BEFORE IT. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.permissions( + typed_guids={"LOGICAL_TABLE": ["good-1", "BOOM-1", "good-2"]}, + compat_ts_version=ANY_MODERN_TS_VERSION, + failures=failures, + http=client, + ) + + results = asyncio.run(scenario()) + + # ONE PAYLOAD PER SURVIVING REQUEST -- THE SHAPE ITS TRANSFORMER EXPECTS. + assert results == [[{"metadata_id": "OK"}], [{"metadata_id": "OK"}]] + assert len(failures) == 1 + assert failures[0].metadata_type == "LOGICAL_TABLE" + assert list(failures[0].identifiers) == ["BOOM-1"] + assert isinstance(failures[0].error, httpx.HTTPError) + + +def test_e_a_failing_column_permission_request_reports_the_whole_column_batch(): + # COLUMN-ACCESS CALLERS PASS GROUPED LISTS (ONE PER TABLE); THE FAILURE RECORD MUST NAME + # EVERY COLUMN THE LOST REQUEST COVERED. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadError("simulated dropped response"))) + good = ["good-1", "good-2"] + bad = ["BOOM-1", "BOOM-2"] + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.permissions( typed_guids={"LOGICAL_COLUMN": [good, bad]}, + compat_ts_version=ANY_MODERN_TS_VERSION, + failures=failures, + http=client, + ) + + results = asyncio.run(scenario()) + + assert results == [[{"metadata_id": "OK"}]] + assert len(failures) == 1 + assert list(failures[0].identifiers) == bad + + +def test_e_a_healthy_permissions_run_reports_no_failures(): + server = RecordingServer(respond=lambda _: 200) + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> list: + client = make_client(server) + return await metadata_workflow.permissions( + typed_guids={"LOGICAL_TABLE": ["tbl-1", "tbl-2"]}, + compat_ts_version=ANY_MODERN_TS_VERSION, + failures=failures, + http=client, + ) + + results = asyncio.run(scenario()) + + assert failures == [] + assert len(results) == 2 + + +def test_f_dependent_fetch_failures_name_the_kind_of_fetch(caplog): + # "25 LOGICAL_COLUMN objects" ALONE DOESN'T SAY WHAT WAS LOST -- THE SAME COLUMNS APPEAR + # IN THE DETAILS, DEPENDENTS, AND COLUMN-ACCESS PHASES. THE MESSAGE MUST NAME THE KIND. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + bad = [f"BOOM-{i}" for i in range(EXPECTED_MAX_PER_REQUEST)] + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> None: + client = make_client(server) + await metadata_workflow.fetch( + typed_guids={"LOGICAL_COLUMN": [bad]}, include_dependent_objects=True, dependent_objects_record_size=-1, + failures=failures, + http=client, + ) + + with caplog.at_level(logging.ERROR, logger="cs_tools.api.workflows.metadata"): + asyncio.run(scenario()) + + assert "Could not fetch dependents for" in caplog.text + assert failures[0].fetched == "dependents" + + +def test_f_permission_fetch_failures_name_the_kind_of_fetch(caplog): + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> None: + client = make_client(server) + await metadata_workflow.permissions( + typed_guids={"LOGICAL_TABLE": ["BOOM-1"]}, + compat_ts_version=ANY_MODERN_TS_VERSION, + failures=failures, http=client, ) - with pytest.raises(_compat.ExceptionGroup): + with caplog.at_level(logging.ERROR, logger="cs_tools.api.workflows.metadata"): asyncio.run(scenario()) + + assert "Could not fetch permissions for" in caplog.text + assert failures[0].fetched == "permissions" + + +def test_f_a_plain_fetch_still_says_data(caplog): + # CALLERS WHICH SET NEITHER FLAG GET THE GENERIC WORDING. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout("simulated slow endpoint"))) + failures: list[metadata_workflow.FetchFailure] = [] + + async def scenario() -> None: + client = make_client(server) + await metadata_workflow.fetch(typed_guids={"LOGICAL_TABLE": ["BOOM-1"]}, failures=failures, http=client) + + with caplog.at_level(logging.ERROR, logger="cs_tools.api.workflows.metadata"): + asyncio.run(scenario()) + + assert "Could not fetch data for" in caplog.text + assert failures[0].fetched == "data" + + +def test_f_an_error_with_no_message_has_no_dangling_colon(caplog): + # ReadTimeout OFTEN CARRIES NO TEXT -- "(ReadTimeout: )" READS AS A BROKEN MESSAGE. + server = RecordingServer(respond=fails_on(b"BOOM", httpx.ReadTimeout(""))) + + async def scenario() -> None: + client = make_client(server) + await metadata_workflow.fetch(typed_guids={"LOGICAL_TABLE": ["BOOM-1"]}, http=client) + + with caplog.at_level(logging.ERROR, logger="cs_tools.api.workflows.metadata"): + asyncio.run(scenario()) + + assert "(ReadTimeout)" in caplog.text + assert "ReadTimeout: " not in caplog.text diff --git a/uv.lock b/uv.lock index 95ae6e4b..622b809b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,11 @@ version = 1 requires-python = ">=3.9.2" +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10'", +] [[package]] name = "aiohappyeyeballs" @@ -169,6 +175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -272,6 +287,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, ] +[[package]] +name = "boto3" +version = "1.42.97" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jmespath", marker = "python_full_version < '3.10'" }, + { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/43/84c1888139aa1aaf1dc53f8f914e6ec629e5a571fbafdd42fb2d98ac361f/boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd", size = 140556 }, +] + +[[package]] +name = "boto3" +version = "1.43.55" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "botocore", version = "1.43.55", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/62/9a6795d2e85b04615a18bf25d39e9cfea243f7bd532f420eeee17045f157/boto3-1.43.55.tar.gz", hash = "sha256:ab2d18d00fd0ceb7110c3659176b9306e009a313dbc5fcc5bd8734253f115157", size = 112686 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/b4/354b0a469e7b215efe4ec9b4ef942ae14becedbfd14bf3f87bf72fb7350a/boto3-1.43.55-py3-none-any.whl", hash = "sha256:c8f23355b4eff538a9c04e09666c6defb5013051de23ca826559906787253188", size = 140026 }, +] + +[[package]] +name = "botocore" +version = "1.42.97" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "jmespath", marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367 }, +] + +[[package]] +name = "botocore" +version = "1.43.55" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/eb/a235d69083f5948d3ed7e1e5d75ceb16f5bf943761e33b54cb141c497ed1/botocore-1.43.55.tar.gz", hash = "sha256:46e8d9f457a804948abf45a22add963ebaaa6c2765c2f1c26ff3b534309d7a58", size = 15733294 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/23/0c7172020da4ff6d19e6aefa98abc024955dba2c87194b71ac48990d3e84/botocore-1.43.55-py3-none-any.whl", hash = "sha256:b7ceb3070dffb64cc1cfdda3c32db538eb143c46ad9e9e781b0d8f90c9246f37", size = 15417310 }, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -285,8 +372,13 @@ wheels = [ name = "cffi" version = "1.17.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10'", +] dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "python_full_version < '3.14' or platform_python_implementation == 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ @@ -350,6 +442,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "pycparser", marker = "python_full_version >= '3.14' and implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837 }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226 }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107 }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733 }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543 }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460 }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760 }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230 }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524 }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341 }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578 }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071 }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845 }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186 }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892 }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793 }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737 }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909 }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883 }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251 }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250 }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441 }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496 }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113 }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927 }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829 }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728 }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815 }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429 }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315 }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859 }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844 }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287 }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681 }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269 }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881 }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088 }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331 }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966 }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795 }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746 }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747 }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392 }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285 }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801 }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808 }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241 }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588 }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248 }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717 }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114 }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142 }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819 }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965 }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952 }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353 }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051 }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630 }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146 }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240 }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723 }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937 }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001 }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538 }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230 }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899 }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652 }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755 }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933 }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749 }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703 }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857 }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065 }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404 }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121 }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820 }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936 }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045 }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342 }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073 }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551 }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649 }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203 }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263 }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696 }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914 }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004 }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378 }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319 }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904 }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554 }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795 }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843 }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773 }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719 }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769 }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405 }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -435,14 +640,14 @@ wheels = [ [[package]] name = "click" -version = "8.1.8" +version = "8.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, ] [[package]] @@ -527,11 +732,15 @@ toml = [ name = "cryptography" version = "44.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } wheels = [ + { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, @@ -539,6 +748,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, + { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, + { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, @@ -546,20 +758,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, + { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, + { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, + { url = "https://files.pythonhosted.org/packages/77/d4/fea74422326388bbac0c37b7489a0fcb1681a698c3b875959430ba550daa/cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731", size = 3338857 }, { url = "https://files.pythonhosted.org/packages/1a/aa/ba8a7467c206cb7b62f09b4168da541b5109838627f582843bbbe0235e8e/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4", size = 3850615 }, { url = "https://files.pythonhosted.org/packages/89/fa/b160e10a64cc395d090105be14f399b94e617c879efd401188ce0fea39ee/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756", size = 4081622 }, { url = "https://files.pythonhosted.org/packages/47/8f/20ff0656bb0cf7af26ec1d01f780c5cfbaa7666736063378c5f48558b515/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c", size = 3867546 }, { url = "https://files.pythonhosted.org/packages/38/d9/28edf32ee2fcdca587146bcde90102a7319b2f2c690edfa627e46d586050/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa", size = 4090937 }, + { url = "https://files.pythonhosted.org/packages/cc/9d/37e5da7519de7b0b070a3fedd4230fe76d50d2a21403e0f2153d70ac4163/cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", size = 3128774 }, +] + +[[package]] +name = "cryptography" +version = "45.0.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105 }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799 }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504 }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542 }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244 }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975 }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082 }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397 }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244 }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862 }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578 }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400 }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824 }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233 }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075 }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517 }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893 }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132 }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086 }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383 }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186 }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639 }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552 }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742 }, + { url = "https://files.pythonhosted.org/packages/13/3e/e42f1528ca1ea82256b835191eab1be014e0f9f934b60d98b0be8a38ed70/cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252", size = 3572442 }, + { url = "https://files.pythonhosted.org/packages/59/aa/e947693ab08674a2663ed2534cd8d345cf17bf6a1facf99273e8ec8986dc/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083", size = 4142233 }, + { url = "https://files.pythonhosted.org/packages/24/06/09b6f6a2fc43474a32b8fe259038eef1500ee3d3c141599b57ac6c57612c/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130", size = 4376202 }, + { url = "https://files.pythonhosted.org/packages/00/f2/c166af87e95ce6ae6d38471a7e039d3a0549c2d55d74e059680162052824/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4", size = 4141900 }, + { url = "https://files.pythonhosted.org/packages/16/b9/e96e0b6cb86eae27ea51fa8a3151535a18e66fe7c451fa90f7f89c85f541/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141", size = 4375562 }, + { url = "https://files.pythonhosted.org/packages/36/d0/36e8ee39274e9d77baf7d0dafda680cba6e52f3936b846f0d56d64fec915/cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7", size = 3322781 }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634 }, + { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533 }, + { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557 }, + { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023 }, + { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722 }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908 }, +] + +[[package]] +name = "cryptography" +version = "46.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "cffi", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/ee/04cd4314db26ffc951c1ea90bde30dd226880ab9343759d7abbecef377ee/cryptography-46.0.0.tar.gz", hash = "sha256:99f64a6d15f19f3afd78720ad2978f6d8d4c68cd4eb600fab82ab1a7c2071dca", size = 749158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/bd/3e935ca6e87dc4969683f5dd9e49adaf2cb5734253d93317b6b346e0bd33/cryptography-46.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:c9c4121f9a41cc3d02164541d986f59be31548ad355a5c96ac50703003c50fb7", size = 7285468 }, + { url = "https://files.pythonhosted.org/packages/c7/ee/dd17f412ce64b347871d7752657c5084940d42af4d9c25b1b91c7ee53362/cryptography-46.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f70cbade61a16f5e238c4b0eb4e258d177a2fcb59aa0aae1236594f7b0ae338", size = 4308218 }, + { url = "https://files.pythonhosted.org/packages/2f/53/f0b865a971e4e8b3e90e648b6f828950dea4c221bb699421e82ef45f0ef9/cryptography-46.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1eccae15d5c28c74b2bea228775c63ac5b6c36eedb574e002440c0bc28750d3", size = 4571982 }, + { url = "https://files.pythonhosted.org/packages/d4/c8/035be5fd63a98284fd74df9e04156f9fed7aa45cef41feceb0d06cbdadd0/cryptography-46.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1b4fba84166d906a22027f0d958e42f3a4dbbb19c28ea71f0fb7812380b04e3c", size = 4307996 }, + { url = "https://files.pythonhosted.org/packages/aa/4a/dbb6d7d0a48b95984e2d4caf0a4c7d6606cea5d30241d984c0c02b47f1b6/cryptography-46.0.0-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:523153480d7575a169933f083eb47b1edd5fef45d87b026737de74ffeb300f69", size = 4015692 }, + { url = "https://files.pythonhosted.org/packages/65/48/aafcffdde716f6061864e56a0a5908f08dcb8523dab436228957c8ebd5df/cryptography-46.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f09a3a108223e319168b7557810596631a8cb864657b0c16ed7a6017f0be9433", size = 4982192 }, + { url = "https://files.pythonhosted.org/packages/4c/ab/1e73cfc181afc3054a09e5e8f7753a8fba254592ff50b735d7456d197353/cryptography-46.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c1f6ccd6f2eef3b2eb52837f0463e853501e45a916b3fc42e5d93cf244a4b97b", size = 4603944 }, + { url = "https://files.pythonhosted.org/packages/3a/02/d71dac90b77c606c90c366571edf264dc8bd37cf836e7f902253cbf5aa77/cryptography-46.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:80a548a5862d6912a45557a101092cd6c64ae1475b82cef50ee305d14a75f598", size = 4308149 }, + { url = "https://files.pythonhosted.org/packages/29/e6/4dcb67fdc6addf4e319a99c4bed25776cb691f3aa6e0c4646474748816c6/cryptography-46.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6c39fd5cd9b7526afa69d64b5e5645a06e1b904f342584b3885254400b63f1b3", size = 4947449 }, + { url = "https://files.pythonhosted.org/packages/26/04/91e3fad8ee33aa87815c8f25563f176a58da676c2b14757a4d3b19f0253c/cryptography-46.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d5c0cbb2fb522f7e39b59a5482a1c9c5923b7c506cfe96a1b8e7368c31617ac0", size = 4603549 }, + { url = "https://files.pythonhosted.org/packages/9c/6e/caf4efadcc8f593cbaacfbb04778f78b6d0dac287b45cec25e5054de38b7/cryptography-46.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6d8945bc120dcd90ae39aa841afddaeafc5f2e832809dc54fb906e3db829dfdc", size = 4435976 }, + { url = "https://files.pythonhosted.org/packages/c1/c0/704710f349db25c5b91965c3662d5a758011b2511408d9451126429b6cd6/cryptography-46.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:88c09da8a94ac27798f6b62de6968ac78bb94805b5d272dbcfd5fdc8c566999f", size = 4709447 }, + { url = "https://files.pythonhosted.org/packages/91/5e/ff63bfd27b75adaf75cc2398de28a0b08105f9d7f8193f3b9b071e38e8b9/cryptography-46.0.0-cp311-abi3-win32.whl", hash = "sha256:3738f50215211cee1974193a1809348d33893696ce119968932ea117bcbc9b1d", size = 3058317 }, + { url = "https://files.pythonhosted.org/packages/46/47/4caf35014c4551dd0b43aa6c2e250161f7ffcb9c3918c9e075785047d5d2/cryptography-46.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bbaa5eef3c19c66613317dc61e211b48d5f550db009c45e1c28b59d5a9b7812a", size = 3523891 }, + { url = "https://files.pythonhosted.org/packages/98/66/6a0cafb3084a854acf808fccf756cbc9b835d1b99fb82c4a15e2e2ffb404/cryptography-46.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:16b5ac72a965ec9d1e34d9417dbce235d45fa04dac28634384e3ce40dfc66495", size = 2932145 }, + { url = "https://files.pythonhosted.org/packages/f2/5f/0cf967a1dc1419d5dde111bd0e22872038199f4e4655539ea6f4da5ad7f1/cryptography-46.0.0-cp314-abi3-macosx_10_9_universal2.whl", hash = "sha256:91585fc9e696abd7b3e48a463a20dda1a5c0eeeca4ba60fa4205a79527694390", size = 7203952 }, + { url = "https://files.pythonhosted.org/packages/9c/9e/d20925af5f0484c5049cf7254c91b79776a9b555af04493de6bdd419b495/cryptography-46.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:65e9117ebed5b16b28154ed36b164c20021f3a480e9cbb4b4a2a59b95e74c25d", size = 4293519 }, + { url = "https://files.pythonhosted.org/packages/5f/b9/07aec6b183ef0054b5f826ae43f0b4db34c50b56aff18f67babdcc2642a3/cryptography-46.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:da7f93551d39d462263b6b5c9056c49f780b9200bf9fc2656d7c88c7bdb9b363", size = 4545583 }, + { url = "https://files.pythonhosted.org/packages/39/4a/7d25158be8c607e2b9ebda49be762404d675b47df335d0d2a3b979d80213/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:be7479f9504bfb46628544ec7cb4637fe6af8b70445d4455fbb9c395ad9b7290", size = 4299196 }, + { url = "https://files.pythonhosted.org/packages/15/3f/65c8753c0dbebe769cc9f9d87d52bce8b74e850ef2818c59bfc7e4248663/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f85e6a7d42ad60024fa1347b1d4ef82c4df517a4deb7f829d301f1a92ded038c", size = 3994419 }, + { url = "https://files.pythonhosted.org/packages/d5/b4/69a271873cfc333a236443c94aa07e0233bc36b384e182da2263703b5759/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:d349af4d76a93562f1dce4d983a4a34d01cb22b48635b0d2a0b8372cdb4a8136", size = 4960228 }, + { url = "https://files.pythonhosted.org/packages/af/e0/ab62ee938b8d17bd1025cff569803cfc1c62dfdf89ffc78df6e092bff35f/cryptography-46.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:35aa1a44bd3e0efc3ef09cf924b3a0e2a57eda84074556f4506af2d294076685", size = 4577257 }, + { url = "https://files.pythonhosted.org/packages/49/67/09a581c21da7189676678edd2bd37b64888c88c2d2727f2c3e0350194fba/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c457ad3f151d5fb380be99425b286167b358f76d97ad18b188b68097193ed95a", size = 4299023 }, + { url = "https://files.pythonhosted.org/packages/af/28/2cb6d3d0d2c8ce8be4f19f4d83956c845c760a9e6dfe5b476cebed4f4f00/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:399ef4c9be67f3902e5ca1d80e64b04498f8b56c19e1bc8d0825050ea5290410", size = 4925802 }, + { url = "https://files.pythonhosted.org/packages/88/0b/1f31b6658c1dfa04e82b88de2d160e0e849ffb94353b1526dfb3a225a100/cryptography-46.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:378eff89b040cbce6169528f130ee75dceeb97eef396a801daec03b696434f06", size = 4577107 }, + { url = "https://files.pythonhosted.org/packages/c2/af/507de3a1d4ded3068ddef188475d241bfc66563d99161585c8f2809fee01/cryptography-46.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3648d6a5878fd1c9a22b1d43fa75efc069d5f54de12df95c638ae7ba88701d0", size = 4422506 }, + { url = "https://files.pythonhosted.org/packages/47/aa/08e514756504d92334cabfe7fe792d10d977f2294ef126b2056b436450eb/cryptography-46.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fc30be952dd4334801d345d134c9ef0e9ccbaa8c3e1bc18925cbc4247b3e29c", size = 4684081 }, + { url = "https://files.pythonhosted.org/packages/d0/ef/ffde6e334fbd4ace04a6d9ced4c5fe1ca9e6ded4ee21b077a6889b452a89/cryptography-46.0.0-cp314-cp314t-win32.whl", hash = "sha256:b8e7db4ce0b7297e88f3d02e6ee9a39382e0efaf1e8974ad353120a2b5a57ef7", size = 3029735 }, + { url = "https://files.pythonhosted.org/packages/4a/78/a41aee8bc5659390806196b0ed4d388211d3b38172827e610a82a7cd7546/cryptography-46.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40ee4ce3c34acaa5bc347615ec452c74ae8ff7db973a98c97c62293120f668c6", size = 3502172 }, + { url = "https://files.pythonhosted.org/packages/f0/2b/7e7427c258fdeae867d236cc9cad0c5c56735bc4d2f4adf035933ab4c15f/cryptography-46.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:07a1be54f995ce14740bf8bbe1cc35f7a37760f992f73cf9f98a2a60b9b97419", size = 2912344 }, + { url = "https://files.pythonhosted.org/packages/53/06/80e7256a4677c2e9eb762638e8200a51f6dd56d2e3de3e34d0a83c2f5f80/cryptography-46.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:1d2073313324226fd846e6b5fc340ed02d43fd7478f584741bd6b791c33c9fee", size = 7257206 }, + { url = "https://files.pythonhosted.org/packages/3d/b8/a5ed987f5c11b242713076121dddfff999d81fb492149c006a579d0e4099/cryptography-46.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83af84ebe7b6e9b6de05050c79f8cc0173c864ce747b53abce6a11e940efdc0d", size = 4301182 }, + { url = "https://files.pythonhosted.org/packages/da/94/f1c1f30110c05fa5247bf460b17acfd52fa3f5c77e94ba19cff8957dc5e6/cryptography-46.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c3cd09b1490c1509bf3892bde9cef729795fae4a2fee0621f19be3321beca7e4", size = 4562561 }, + { url = "https://files.pythonhosted.org/packages/5d/54/8decbf2f707350bedcd525833d3a0cc0203d8b080d926ad75d5c4de701ba/cryptography-46.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d14eaf1569d6252280516bedaffdd65267428cdbc3a8c2d6de63753cf0863d5e", size = 4301974 }, + { url = "https://files.pythonhosted.org/packages/82/63/c34a2f3516c6b05801f129616a5a1c68a8c403b91f23f9db783ee1d4f700/cryptography-46.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab3a14cecc741c8c03ad0ad46dfbf18de25218551931a23bca2731d46c706d83", size = 4009462 }, + { url = "https://files.pythonhosted.org/packages/cd/c5/92ef920a4cf8ff35fcf9da5a09f008a6977dcb9801c709799ec1bf2873fb/cryptography-46.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8e8b222eb54e3e7d3743a7c2b1f7fa7df7a9add790307bb34327c88ec85fe087", size = 4980769 }, + { url = "https://files.pythonhosted.org/packages/a9/8f/1705f7ea3b9468c4a4fef6cce631db14feb6748499870a4772993cbeb729/cryptography-46.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7f3f88df0c9b248dcc2e76124f9140621aca187ccc396b87bc363f890acf3a30", size = 4591812 }, + { url = "https://files.pythonhosted.org/packages/34/b9/2d797ce9d346b8bac9f570b43e6e14226ff0f625f7f6f2f95d9065e316e3/cryptography-46.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9aa85222f03fdb30defabc7a9e1e3d4ec76eb74ea9fe1504b2800844f9c98440", size = 4301844 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/8efc9712997b46aea2ac8f74adc31f780ac4662e3b107ecad0d5c1a0c7f8/cryptography-46.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f9aaf2a91302e1490c068d2f3af7df4137ac2b36600f5bd26e53d9ec320412d3", size = 4943257 }, + { url = "https://files.pythonhosted.org/packages/c4/0c/bc365287a97d28aa7feef8810884831b2a38a8dc4cf0f8d6927ad1568d27/cryptography-46.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:32670ca085150ff36b438c17f2dfc54146fe4a074ebf0a76d72fb1b419a974bc", size = 4591154 }, + { url = "https://files.pythonhosted.org/packages/51/3b/0b15107277b0c558c02027da615f4e78c892f22c6a04d29c6ad43fcddca6/cryptography-46.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0f58183453032727a65e6605240e7a3824fd1d6a7e75d2b537e280286ab79a52", size = 4428200 }, + { url = "https://files.pythonhosted.org/packages/cf/24/814d69418247ea2cfc985eec6678239013500d745bc7a0a35a32c2e2f3be/cryptography-46.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bc257c2d5d865ed37d0bd7c500baa71f939a7952c424f28632298d80ccd5ec1", size = 4699862 }, + { url = "https://files.pythonhosted.org/packages/fb/1e/665c718e0c45281a4e22454fa8a9bd8835f1ceb667b9ffe807baa41cd681/cryptography-46.0.0-cp38-abi3-win32.whl", hash = "sha256:df932ac70388be034b2e046e34d636245d5eeb8140db24a6b4c2268cd2073270", size = 3043766 }, + { url = "https://files.pythonhosted.org/packages/78/7e/12e1e13abff381c702697845d1cf372939957735f49ef66f2061f38da32f/cryptography-46.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:274f8b2eb3616709f437326185eb563eb4e5813d01ebe2029b61bfe7d9995fbb", size = 3517216 }, + { url = "https://files.pythonhosted.org/packages/ad/55/009497b2ae7375db090b41f9fe7a1a7362f804ddfe17ed9e34f748fcb0e5/cryptography-46.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:249c41f2bbfa026615e7bdca47e4a66135baa81b08509ab240a2e666f6af5966", size = 2923145 }, + { url = "https://files.pythonhosted.org/packages/61/d0/367ff74316d94fbe273e49f441b111a88daa8945a10baf2cd2d35f4e7077/cryptography-46.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fe9ff1139b2b1f59a5a0b538bbd950f8660a39624bbe10cf3640d17574f973bb", size = 3715000 }, + { url = "https://files.pythonhosted.org/packages/9c/c7/43f68f1fe9363268e34d1026e3f3f99f0ed0f632a49a8867187161215be0/cryptography-46.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:77e3bd53c9c189cea361bc18ceb173959f8b2dd8f8d984ae118e9ac641410252", size = 3443876 }, + { url = "https://files.pythonhosted.org/packages/d2/c9/fd0ac99ac18eaa8766800bf7d087e8c011889aa6643006cff9cbd523eadd/cryptography-46.0.0-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:75d2ddde8f1766ab2db48ed7f2aa3797aeb491ea8dfe9b4c074201aec00f5c16", size = 3722472 }, + { url = "https://files.pythonhosted.org/packages/f5/69/ff831514209e68a7e32fef655abfd9ef9ee4608d151636fa11eb8d7e589a/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f9f85d9cf88e3ba2b2b6da3c2310d1cf75bdf04a5bc1a2e972603054f82c4dd5", size = 4249520 }, + { url = "https://files.pythonhosted.org/packages/19/4a/19960010da2865f521a5bd657eaf647d6a4368568e96f6d9ec635e47ad55/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:834af45296083d892e23430e3b11df77e2ac5c042caede1da29c9bf59016f4d2", size = 4528031 }, + { url = "https://files.pythonhosted.org/packages/79/92/88970c2b5b270d232213a971e74afa6d0e82d8aeee0964765a78ee1f55c8/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:c39f0947d50f74b1b3523cec3931315072646286fb462995eb998f8136779319", size = 4249072 }, + { url = "https://files.pythonhosted.org/packages/63/50/b0b90a269d64b479602d948f40ef6131f3704546ce003baa11405aa4093b/cryptography-46.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6460866a92143a24e3ed68eaeb6e98d0cedd85d7d9a8ab1fc293ec91850b1b38", size = 4527173 }, + { url = "https://files.pythonhosted.org/packages/37/e1/826091488f6402c904e831ccbde41cf1a08672644ee5107e2447ea76a903/cryptography-46.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bf1961037309ee0bdf874ccba9820b1c2f720c2016895c44d8eb2316226c1ad5", size = 3448199 }, ] [[package]] name = "cs-tools" -version = "1.6.3" +version = "1.6.8" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "awesomeversion" }, { name = "betterproto", extra = ["compiler"] }, + { name = "click" }, { name = "email-validator" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "httpx" }, @@ -567,6 +902,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, + { name = "snowflake-sqlalchemy" }, { name = "sqlmodel" }, { name = "strenum", marker = "python_full_version < '3.11'" }, { name = "taskgroup", marker = "python_full_version < '3.11'" }, @@ -616,6 +952,7 @@ requires-dist = [ { name = "aiosqlite" }, { name = "awesomeversion" }, { name = "betterproto", extras = ["compiler"], specifier = "==2.0.0b6" }, + { name = "click", specifier = "==8.1.7" }, { name = "coverage", extras = ["toml"], marker = "extra == 'dev'" }, { name = "email-validator" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -634,8 +971,9 @@ requires-dist = [ { name = "pydantic-settings" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "python-slugify", extras = ["unidecode"], marker = "extra == 'docs'" }, - { name = "rich", specifier = ">=13.7.1" }, + { name = "rich", specifier = "==13.7.1" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "snowflake-sqlalchemy", specifier = ">=1.7.4" }, { name = "sqlmodel", specifier = ">=0.0.16" }, { name = "strenum", marker = "python_full_version < '3.11'" }, { name = "taskgroup", marker = "python_full_version < '3.11'" }, @@ -647,7 +985,7 @@ requires-dist = [ { name = "textual-serve", marker = "extra == 'cli'", specifier = "==1.1.1" }, { name = "textual-serve", marker = "extra == 'dev'", specifier = "==1.1.1" }, { name = "textual-serve", marker = "extra == 'docs'", specifier = "==1.1.1" }, - { name = "thoughtspot-tml" }, + { name = "thoughtspot-tml", specifier = ">=2.4.2" }, { name = "toml" }, { name = "typer", marker = "extra == 'cli'", specifier = "==0.12.0" }, { name = "typer", marker = "extra == 'dev'", specifier = "==0.12.0" }, @@ -1103,6 +1441,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, +] + [[package]] name = "keyring" version = "25.6.0" @@ -1318,7 +1665,8 @@ dependencies = [ { name = "pygments" }, { name = "pymdown-extensions" }, { name = "regex" }, - { name = "requests" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/1e/65b4fda4debf5e337b2ad4e692423dba4f5c77f49c4dee170c47a7dbac25/mkdocs_material-9.6.3.tar.gz", hash = "sha256:c87f7d1c39ce6326da5e10e232aed51bae46252e646755900f4b0fc9192fa832", size = 3942608 } wheels = [ @@ -1904,6 +2252,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, +] + [[package]] name = "pymdown-extensions" version = "10.14.3" @@ -1917,6 +2277,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, ] +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "cryptography", version = "44.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771 }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268 }, +] + [[package]] name = "pytest" version = "8.3.4" @@ -1972,6 +2367,15 @@ unidecode = [ { name = "unidecode" }, ] +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2135,29 +2539,51 @@ wheels = [ name = "requests" version = "2.32.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + [[package]] name = "rich" -version = "13.9.4" +version = "13.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", size = 221248 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, + { url = "https://files.pythonhosted.org/packages/87/67/a37f6214d0e9fe57f6ae54b2956d550ca8365857f42a1ce0392bb21d9410/rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", size = 240681 }, ] [[package]] @@ -2185,12 +2611,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/d9/fde7610abd53c0c76b6af72fc679cb377b27c617ba704e25da834e0a0608/ruff-0.9.5-py3-none-win_arm64.whl", hash = "sha256:18a29f1a005bddb229e580795627d297dfa99f16b30c7039e73278cf6b5f9fa9", size = 10064595 }, ] +[[package]] +name = "s3transfer" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825 }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "botocore", version = "1.43.55", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216 }, +] + [[package]] name = "secretstorage" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, + { name = "cryptography", version = "44.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } @@ -2225,6 +2685,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "snowflake-connector-python" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asn1crypto", marker = "python_full_version < '3.10'" }, + { name = "boto3", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "cryptography", version = "44.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", marker = "python_full_version < '3.10'" }, + { name = "pyjwt", marker = "python_full_version < '3.10'" }, + { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytz", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.10'" }, + { name = "tomlkit", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/4aff125021a9c5e0183f2f55dd7d04b7256a0e1e10db50d537a7415d9c55/snowflake_connector_python-4.0.0.tar.gz", hash = "sha256:4b10a865c4a5e1fa60c365c7fe41e0433605e6e5edc824e8730a9038f330b3a6", size = 813937 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/75/f845ca5079a6b911023fa945dbf1bac0ed1c2f5967108b14440c740cb410/snowflake_connector_python-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2c3e0f6d103fe67c975550ed424f579d3e7ae503d56467e5549f3a1a1e0e8f24", size = 1030251 }, + { url = "https://files.pythonhosted.org/packages/fd/80/3a7e36a9e53beeb27c0599d2703f33bb812be931b469b154b08df0eeeaf5/snowflake_connector_python-4.0.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:e8d5b66f283967c700fff2303ac5e52d1a3cf41990a634f121ac8b1f1cd9af10", size = 1043041 }, + { url = "https://files.pythonhosted.org/packages/6e/3b/bda95c4de593743c021a9968d70087674189c60a8317185de1b0f32d17c8/snowflake_connector_python-4.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad5d0f1ebcb2c6b7a7859ee3d4e02203087e40faae539a336bbcb45a3660777", size = 2666209 }, + { url = "https://files.pythonhosted.org/packages/60/e6/30c4015e2712bf8bf83b54ddadeee0494b68ae6d0f6d49d9373f463305d4/snowflake_connector_python-4.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4106a66e770e564b3037457b7b01b15ca28aee61afb88560b664aa8af439b533", size = 2693962 }, + { url = "https://files.pythonhosted.org/packages/1b/e0/5f494b3353216e629f8b9d7269024518e0db9f2992df471de6ef43b60f7b/snowflake_connector_python-4.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7789df78f7c7abfb351f2709258d05a94652cfe3c2c617fb15f15a11fc1b7b25", size = 1177353 }, + { url = "https://files.pythonhosted.org/packages/0c/86/0dceb37f50cd28ee61af1f0396eccd17a563d56d66067e0842ff8dfafe6d/snowflake_connector_python-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ca2503f705627f7e045da6254d97c37210a3b0a18b43d0f1b29616d0c7aaa01", size = 1030446 }, + { url = "https://files.pythonhosted.org/packages/7b/80/1ac8ae9494b2ee5bcc0e4cefe5aada86d0e61d21208a0107e99c5bec92ec/snowflake_connector_python-4.0.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:fd0d2d2c5cfd15f041e8522f5f8bdad0be4de7d805dd1646377fccd6bd404fa8", size = 1043031 }, + { url = "https://files.pythonhosted.org/packages/fe/db/fbd1dbe2d6ca2b8f99337e39d596a800e6c54451a93a9167927a15f60534/snowflake_connector_python-4.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbdeec0d65c2e3f648c8b05839001c062984959417902717f7fc6eed983211d", size = 2677427 }, + { url = "https://files.pythonhosted.org/packages/12/2b/ecb82ae3e07c65a0d137c9211570db63885843ec2a40eb71e168cc834bc4/snowflake_connector_python-4.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e376bad497c7932448cc29058e75737f02b3f0e25569de9e4ff0616944b4ceba", size = 2707806 }, + { url = "https://files.pythonhosted.org/packages/76/f6/99a373042dcd4a83459ce915b1cdd22c3ac54bd8c2a727bb299cec85e0b2/snowflake_connector_python-4.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:af89a9e1355ea4dac7927d2f9bc15b2c81e381ad8bcdf8954ba3dd457a4d51d6", size = 1177415 }, + { url = "https://files.pythonhosted.org/packages/ea/b0/462c0deee35d6d03d3d729b3f923615bae665beb7f9a94673a23a52080fe/snowflake_connector_python-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bfd3b8523d7adc830f99c5c4c635689ceca61700a05368d5bbb34c6811f2ec54", size = 1029568 }, + { url = "https://files.pythonhosted.org/packages/ff/4b/bb3ae3f07e7927c8f16c4c0f1283d3c721978d16e8bf4193fc8e41025c1e/snowflake_connector_python-4.0.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:835161dd46ef8f5fc9d2f135ca654c2f3fbdf57b035d3e1980506aa8eac671dc", size = 1041337 }, + { url = "https://files.pythonhosted.org/packages/9c/75/4bfac89f10c6dbb75e97adf1e217737fc599ebf964031c9298b6cbd807d0/snowflake_connector_python-4.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65e4e36dd1b0c7235d84cddef8a3c97c5ea0dc8fea85e31e45fc485000b77a83", size = 2699730 }, + { url = "https://files.pythonhosted.org/packages/cd/78/0e916416c50909dbae511fe38b1e671a9efa62decdce51b174a0396804e4/snowflake_connector_python-4.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6132986d6965e4005b0167270612fbc7fa4bc4ef42726a40b85a8f57475a78d", size = 2731336 }, + { url = "https://files.pythonhosted.org/packages/83/f0/3db8a2f3f5ee724d309c661af739a70d0643070b9b4597728151ef900f9b/snowflake_connector_python-4.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a790f06808e4481c23cfed1396d2c9a786060ddd62408b1fda1a63e1e6bc4b07", size = 1176292 }, + { url = "https://files.pythonhosted.org/packages/64/c0/10dfcce18514d711bf17d7766d24aedfc20d7a5aa0e8311c0d3068baf266/snowflake_connector_python-4.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4e8c3d2ea4055dd4aecc93514030341e300f557f2e86ca21eb47568c461a6f56", size = 1030702 }, + { url = "https://files.pythonhosted.org/packages/16/c1/9d068375ccb341975eb95a87a99176b4b25bb7725e61c8ed62681f2d5123/snowflake_connector_python-4.0.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:1fea301e3d1e8022b9f2ff87dc3be139d5ed7be5e85fab8a6c59d400a02e6d58", size = 1042153 }, + { url = "https://files.pythonhosted.org/packages/5d/ae/f4da6b62e546f48885b63bc1884c935bc293e6da9605ddcd217e21307a63/snowflake_connector_python-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54e648bbd506a0f2f8076f9eafe231b2d4284b1a884528c3a0690391ab2bb54e", size = 2701637 }, + { url = "https://files.pythonhosted.org/packages/88/bf/6cf92dbd1c6d95311894404e2c46db9a06ff6d37bea9a19e667d0bf26362/snowflake_connector_python-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f67d844241a6fed764a8f04d32c0273aedf9159d5162b764748526277c7f8831", size = 2733899 }, + { url = "https://files.pythonhosted.org/packages/5b/c8/7d9a41e1b10c0a2bae86241773a6b55c06e897c74b3cab14ec8315e16b34/snowflake_connector_python-4.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:cd23bff2abc74e34c6123a181c004ead9e6cc8ef2661250892afd64bad24533c", size = 1176311 }, + { url = "https://files.pythonhosted.org/packages/22/0a/df94facadd8ae761f8af72ad8b5d0b58da8bff92426b5a8a2e316301f456/snowflake_connector_python-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b95b29589293ad14d0367428518141995524cfc7dc47d8f3d0c47010f5d974da", size = 1030406 }, + { url = "https://files.pythonhosted.org/packages/b9/51/a64ee5c08b07623aff47006a490aff215a11eb01f9926e838df70f8e7177/snowflake_connector_python-4.0.0-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:0c0204f639fddc58452b0362ba8821cd5153bd7aaa89434d59104bc39f4fa176", size = 1043301 }, + { url = "https://files.pythonhosted.org/packages/d6/bc/46317e7a3cccae180e403f52b60eb6b00a3a7cc6d106a2631329d7975b97/snowflake_connector_python-4.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79b4e7a58e600419f083a63f99e5da89d438faddae1d344e9f9b003754fa231b", size = 2662406 }, + { url = "https://files.pythonhosted.org/packages/93/35/d84948b53194f53f6344838c9d304f262d8bfb2042bbd343df7b14254ec8/snowflake_connector_python-4.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:985cd2f8b6b2e663ef30dc59f1ba8c1becff7683ebc51bd7f14b962407a9fa88", size = 2691051 }, + { url = "https://files.pythonhosted.org/packages/0b/d6/f5e3f407c118119f7f5766e5b3f441cd7277e39c0e15ac1e1f0a80d9e1eb/snowflake_connector_python-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1bb9729fd3bfaae22364491cec4816dda380376ac3be4559a6f6949c6d2833", size = 1178422 }, +] + +[[package]] +name = "snowflake-connector-python" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "asn1crypto", marker = "python_full_version >= '3.10'" }, + { name = "boto3", version = "1.43.55", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.55", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "filelock", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", marker = "python_full_version >= '3.10'" }, + { name = "pyjwt", marker = "python_full_version >= '3.10'" }, + { name = "pyopenssl", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytz", marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.10'" }, + { name = "tomlkit", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/2f/9b0d1ea2196eeb32e9ac3f9cdf0cfc516ad3788333a75f197c3f55888f70/snowflake_connector_python-4.3.0.tar.gz", hash = "sha256:79f150297b39cfd2481b732554fc4d68b43c83c82eb01e670cc4051cffc089d6", size = 922395 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/7a/44267971eeef7385e4a26aa66f94b5bdc3ef736bcc9b00942b900827faae/snowflake_connector_python-4.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3044e6a237b35f750394f199f5e3800dfeb3227c4c8562584877e814d2dc89a", size = 11916166 }, + { url = "https://files.pythonhosted.org/packages/60/d8/e969f1fcab564f8bcabd26a06b64c345c0acee16c3dc9205140b9b7f5c0b/snowflake_connector_python-4.3.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:e5d360d65d42dd97cf82e688a1a7f235b9bc048b4949c9c5c7052ff2783c444e", size = 11929029 }, + { url = "https://files.pythonhosted.org/packages/67/5b/2b5fc947a2b1ef003be9b1a33f27fd505a99a6f312912ab935355cf37b89/snowflake_connector_python-4.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce55b93120f8b429010bf39cc02e739610b6da2ccdd34fcfc0df04849d0fd9d4", size = 2799195 }, + { url = "https://files.pythonhosted.org/packages/f4/da/c9e1a43ef6528dace99139a47ddcf6dab968e811ec222ac6dc51a7e12d74/snowflake_connector_python-4.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7763c0d5f8e6326ec31f8972cc806fb6d3e07b06ca59f67dfcdf02a34219bcbc", size = 2828441 }, + { url = "https://files.pythonhosted.org/packages/bb/75/0a1f326831f00d506dcb5cae6a916da895a394350e22485d8cc00223aff1/snowflake_connector_python-4.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:120463ca391d9deda3bdb185104ba847e12f73c86ef411cfcf827ce49b64d1af", size = 12067537 }, + { url = "https://files.pythonhosted.org/packages/7b/ea/d4206836b28ff74ad836414b811942c5bf2c70d3aec2f8985e4ea1890d50/snowflake_connector_python-4.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:762ffa9673465ccc630aba438d648e0b1a2452ba49669a54a60d1625f36898f3", size = 11916055 }, + { url = "https://files.pythonhosted.org/packages/a4/55/b29070a5b2ec2f7bbb0051a724e5e6c8ba91a2da0086bd691b419d28c1f6/snowflake_connector_python-4.3.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:3e2ce47485862fa14ffbf2732f0fd02aa69a7c68a50d5f6286f34ed17527cf87", size = 11928750 }, + { url = "https://files.pythonhosted.org/packages/e3/48/b1e2d99b1dbb6698cb88385e800b43e30c575bcf5450810803526857b204/snowflake_connector_python-4.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6fa80373b82125552e691f47b603766ed783f3d90a5782564854aa224aee9d1", size = 2811711 }, + { url = "https://files.pythonhosted.org/packages/ca/51/a1b293fba2d63794283f487173a0c0d3b209464b915427a88d0cfa2408c2/snowflake_connector_python-4.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:676b56eedcc268b7e25a447e736eb8bf8bcacfbc71196c94d6f45746672ee6d5", size = 2841077 }, + { url = "https://files.pythonhosted.org/packages/fc/bf/48a0fdb8378e8bcf5448d6c07c495d2b76faa6b910ebcbcf57ffe7e56a0e/snowflake_connector_python-4.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:55163c5d9b93e10d7217aabd56f776b16c0fe13774f8d5db9188824731da9586", size = 12067474 }, + { url = "https://files.pythonhosted.org/packages/54/b0/a23284f8c2ae977251071737287d7648fee4ef08de386f37eb6e971e8609/snowflake_connector_python-4.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c18b5021ffa6de8313f2c7f0ae6050c36bcee7cb33bb23d40a7fdf3e0a751f2", size = 11915171 }, + { url = "https://files.pythonhosted.org/packages/b2/e7/2f91baf604acc4eb7795d7a25b4d414b81a82561dfac2d39c5e103da2947/snowflake_connector_python-4.3.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:9faa9280e41258fb479ec5395b6a17d3dbb316146832e436aed582b300de655e", size = 11926986 }, + { url = "https://files.pythonhosted.org/packages/a1/0b/09342214ec888192f9e7305d0a2d438531613f2a32ff5c2155e1e1964371/snowflake_connector_python-4.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d22c61f4e3d171b0adad3e9211747917c3a978dfb99564307c1ceadb0f0cd", size = 2867063 }, + { url = "https://files.pythonhosted.org/packages/b7/74/a1a2bd427394214bd7752e72fde257495a18d87d3457343ece9fee00e386/snowflake_connector_python-4.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac18b37e03a29014a9c91aac10c7dbdfa11134c620c6f93dd16f4b99b6a38c2a", size = 2899440 }, + { url = "https://files.pythonhosted.org/packages/32/5a/eda0e80c8cbbef24cfc4aa68587674d8ac0f15fded14e5abc296b8568005/snowflake_connector_python-4.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:726435b2769135b6282601efb2cd8fd53f7deb1ff2fb7da93d28141fa3c8b17e", size = 12066477 }, + { url = "https://files.pythonhosted.org/packages/e6/7a/eda732425c713e07d7327f0c98473615814365e1a75c8d67c31c43ed2fa9/snowflake_connector_python-4.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e42dd9af46fa3ad0e61c1aa6a227357cace481916797ecb92dbb14adb61931e1", size = 11916032 }, + { url = "https://files.pythonhosted.org/packages/92/40/9ba14e500d1d92f12f0dac8d5b975606f0f15bee69c4ceadba64a8853b16/snowflake_connector_python-4.3.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:e96aaf23f2b021e0d2aac8ac1b541975cd1f6896d9115eefe0938114e694a562", size = 11927984 }, + { url = "https://files.pythonhosted.org/packages/c1/be/25125ba4b4a1bb211ad8eadff233549cd9a5152c77d92586cd5693ee608f/snowflake_connector_python-4.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e0f66acee330388815fb842f91a46c9cacdefdf02c816354e6adeca8c2c3f86", size = 2832570 }, + { url = "https://files.pythonhosted.org/packages/2d/c1/19144f2e590d55bce17e089017b5dca71fad46a2a0ddb7b1a69a4c91c5c9/snowflake_connector_python-4.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5a8d91c3e0127360bc3de605df9d02ea4d87e4524a50bf2e7c5c4200f9abf78", size = 2866972 }, + { url = "https://files.pythonhosted.org/packages/3f/28/8f4854bcf267f69387ea785758b3cc5fac1a13452359c234f2fc81eb8ffd/snowflake_connector_python-4.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c1356a2c615e120f913e5235fe87ff8aadbb479ad5a5ac5c0a84881d5fbe981d", size = 12066562 }, + { url = "https://files.pythonhosted.org/packages/21/a6/b7dd76e09d0fac34171306e8713a7127d596a0fe97abfd96241765506690/snowflake_connector_python-4.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:486d17332b9228d2e5975755b434e6a282756a447e0c6605d4e797944fa919da", size = 11916470 }, + { url = "https://files.pythonhosted.org/packages/e2/cd/dcfe9bf3730d5ea0a27ef0cce8297576f638fd5bb168791bc79864eae68e/snowflake_connector_python-4.3.0-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:f5291c00f610b266ab8c79b1e008b3d0cb29bb5b86a0007529320921b4a3fc3c", size = 11929356 }, + { url = "https://files.pythonhosted.org/packages/0b/2a/bc27093401293607b732322e0248bb63d31c11f55dbfb90d8264fff4563d/snowflake_connector_python-4.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a65c5c93d14c4d221b780fdb2f07b4dd83e848f39eabd4832778bf0e2694d4", size = 2795808 }, + { url = "https://files.pythonhosted.org/packages/c4/94/5cb6eb5baf847930593f185f929cbcbb36b85d3421049b339b3425bc14ef/snowflake_connector_python-4.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79cbf5e359cfc33b4c4307df1fe8f78cd5aee56f5c50b98a647fa0cd9ba82cfc", size = 2824201 }, + { url = "https://files.pythonhosted.org/packages/8e/6e/6803a31ea542a958303d33c5db9a4a19d78fb2b79e5717d9126c3a3cbaff/snowflake_connector_python-4.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:08f5167a10d380fe66330d5b19d19bd8b4f4af27e9648e3b254e61da025646bf", size = 12068670 }, +] + +[[package]] +name = "snowflake-sqlalchemy" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "snowflake-connector-python", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowflake-connector-python", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/54/f9f7e3701e4b35bcb9fbdc14cdb347133bbe3c5a9e87071649f3934b283d/snowflake_sqlalchemy-1.11.0.tar.gz", hash = "sha256:95866fe499a8ba692f01eb107741050efc553361e1674e3e44ea4a890b4df00c", size = 248059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/9a/f8d0165f5d2447a0d7e10dc309c7ea1af6782aa1b357164d25dcb567b196/snowflake_sqlalchemy-1.11.0-py3-none-any.whl", hash = "sha256:f5fad9e86441812ad1f23aef92a2e612d65408338221ae39b9becedb7329a6e2", size = 117058 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + [[package]] name = "sqlalchemy" version = "2.0.38" @@ -2401,7 +2998,7 @@ wheels = [ [[package]] name = "thoughtspot-tml" -version = "2.3.1" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "betterproto", extra = ["compiler"] }, @@ -2409,9 +3006,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "zipp", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/3d/a01ac3964ad362d066351eaf9052ec1684574322771950ea50c8487da7e5/thoughtspot_tml-2.3.1.tar.gz", hash = "sha256:7801caa2fe7d835625a253f619ae012cf20b5695c345ff12ee73babf2b30d675", size = 149239 } +sdist = { url = "https://files.pythonhosted.org/packages/04/1a/c28fdd51c9949c122568311ff696e9a5fceccfd590f361ec9882c336fd8d/thoughtspot_tml-2.4.4.tar.gz", hash = "sha256:857a470573780f3efec87cf409da5a8432519b063eca42657a39a256602b20d4", size = 157746 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0c/3610532a387a63bdb7bc1546fcf519372df73410a812c97d51de620e477b/thoughtspot_tml-2.3.1-py3-none-any.whl", hash = "sha256:ac204c8cadc81a58b80a26d2030090f0553d685c5f08b9c960a78697bf52e3d7", size = 44610 }, + { url = "https://files.pythonhosted.org/packages/c8/a6/fb87e70bc8c1fdeeee910f50ac5345fd798662f1676b2720ef2377acd8e8/thoughtspot_tml-2.4.4-py3-none-any.whl", hash = "sha256:30f94ec97dd06e070484736a805b9f4f03ae1b5bbcf600db00c438a1bb087fef", size = 47193 }, ] [[package]] @@ -2843,10 +3440,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 }, ] +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225 }, +] + [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.10' and platform_python_implementation == 'PyPy'", +] sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, @@ -3056,7 +3670,8 @@ name = "zstandard" version = "0.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, + { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'PyPy'" }, + { name = "cffi", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_version < '0'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 } wheels = [