diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bbead..d51a2cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- [EV-1960](https://eit-oxford.atlassian.net/browse/EV-1960): refactor lib into tasks, fix mypy issues plus many more QoL fixes. - [EV-2139](https://eit-oxford.atlassian.net/browse/EV-2139): fix mapping csv to use correct batch and sample names with rows per sample ## 2.2.0-RC2 (2025-06-18) diff --git a/pyproject.toml b/pyproject.toml index 96704ae..186fa0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "tenacity==8.2.3", "click>=8.1.7", "python-dotenv>=1.0", + "pytest-mock>=3.14.0", ] [project.scripts] @@ -87,6 +88,9 @@ dev-dependencies = [ "pytest-mock>=3.14.0", ] +[[tool.mypy.overrides]] +module = ["hostile.*", "httpx.*", "tenacity.*", "click.*", "tqdm.*", "dotenv.*"] +ignore_missing_imports = true [tool.pytest.ini_options] markers = [ diff --git a/src/pathogena/batch_upload_apis.py b/src/pathogena/batch_upload_apis.py deleted file mode 100644 index 7f525e4..0000000 --- a/src/pathogena/batch_upload_apis.py +++ /dev/null @@ -1,280 +0,0 @@ -import os -from typing import Any - -import httpx - -from pathogena.constants import DEFAULT_HOST, DEFAULT_PROTOCOL, DEFAULT_UPLOAD_HOST -from pathogena.errors import APIError -from pathogena.util import get_access_token - - -def get_protocol() -> str: - """Get the protocol to use for communication. - - Returns: - str: The protocol (e.g., 'http', 'https'). - """ - if "PATHOGENA_PROTOCOL" in os.environ: - protocol = os.environ["PATHOGENA_PROTOCOL"] - return protocol - else: - return DEFAULT_PROTOCOL - - -def get_host(cli_host: str | None = None) -> str: - """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. - - Args: - cli_host (str | None): The host provided via CLI argument. - - Returns: - str: The resolved hostname. - """ - return ( - cli_host - if cli_host is not None - else os.environ.get("PATHOGENA_HOST", DEFAULT_HOST) - ) - - -def get_upload_host(cli_host: str | None = None) -> str: - """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. - - Args: - cli_host (str | None): The host provided via CLI argument. - - Returns: - str: The resolved hostname. - """ - return ( - cli_host - if cli_host is not None - else os.environ.get("PATHOGENA_UPLOAD_HOST", DEFAULT_UPLOAD_HOST) - ) - - -class UploadAPIClient: - """A class to handle API requests for batch uploads and related operations.""" - - def __init__( - self, - base_url: str = get_upload_host(), - client: httpx.Client | None = None, - upload_session: int | None = None, - ): - """Initialize the APIClient with a base URL and an optional HTTP client. - - Args: - base_url (str): The base URL for the API, e.g api.upload-dev.eit-pathogena.com - client (httpx.Client | None): A custom HTTP client (Client) for making requests. - """ - self.base_url = base_url - self.client = client or httpx.Client() - self.token = get_access_token(get_host()) - self.upload_session = upload_session - - def batches_create( - self, - data: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Creates a batch by making a POST request. - - Args: - data (dict[str, Any] | None): Data to include in the POST request body. - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - url = f"{get_protocol()}://{self.base_url}/api/v1/batches" - response = httpx.Response(httpx.codes.OK) - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise APIError( - f"Failed to create: {response.text}", response.status_code - ) from e - - def batches_samples_start_upload_session_create( - self, - batch_pk: str, - data: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Starts a sample upload session by making a POST request to the backend. - - Args: - batch_pk (int): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - url = f"{get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/sample-files/start-upload-session/" - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - self.upload_session = response.json().get("upload_session") - - response.raise_for_status() # Raise an HTTPError for bad responses - return response.json() - except httpx.HTTPError as e: - raise APIError( - f"Failed to start upload session: {response.text}", - response.status_code, - ) from e - - def batches_uploads_start_file_upload( - self, - batch_pk: str, - data: dict[str, Any] | None = None, - ) -> httpx.Response: - """Starts an upload by making a POST request. - - Args: - batch_pk (str): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - url = f"{get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/uploads/start/" - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - response.raise_for_status() - return response - except httpx.HTTPError as e: - raise APIError( - f"Failed to start batch upload: {response.text}", - response.status_code, - ) from e - - def batches_uploads_upload_chunk( - self, - batch_pk: int, - data: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Starts a batch chunk upload session by making a POST request. - - Args: - batch_pk (int): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - url = f"{get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/uploads/upload-chunk/" - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise APIError( - f"Failed to start batch chunk upload: {response.text}", - response.status_code, - ) from e - - def batches_uploads_end_file_upload( - self, - batch_pk: int, - data: dict[str, Any] | None = None, - ) -> httpx.Response: - """End a batch upload by making a POST request. - - Args: - batch_pk (int): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - url = ( - f"{get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/uploads/end/" - ) - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - response.raise_for_status() - return response - except httpx.HTTPError as e: - raise APIError( - f"Failed to end batch upload: {response.text}", response.status_code - ) from e - - def batches_samples_end_upload_session_create( - self, - batch_pk: str, - upload_session: int | None = None, - ) -> httpx.Response: - """Ends a sample upload session by making a POST request to the backend. - - Args: - batch_pk (str): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - if upload_session is not None: - data = {"upload_session": upload_session} - else: - data = {"upload_session": self.upload_session} - - url = f"{get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/sample-files/end-upload-session/" - try: - response = self.client.post( - url, - json=data, - headers={"Authorization": f"Bearer {self.token}"}, - follow_redirects=True, - ) - response.raise_for_status() # Raise an HTTPError for bad responses - return response - except httpx.HTTPError as e: - raise APIError( - f"Failed to end upload session: {response.text}", - response.status_code, - ) from e diff --git a/src/pathogena/cli.py b/src/pathogena/cli.py index 3c2f018..e650518 100644 --- a/src/pathogena/cli.py +++ b/src/pathogena/cli.py @@ -1,4 +1,4 @@ -import json as json_ +import json import logging import sys from datetime import date, datetime @@ -7,8 +7,8 @@ import click -from pathogena import constants, lib, models, util -from pathogena.create_upload_csv import UploadData, build_upload_csv +from pathogena import constants, models, tasks, util +from pathogena.client import env from pathogena.errors import AuthorizationError from pathogena.log_utils import configure_debug_logging @@ -20,7 +20,7 @@ ) def main(*, debug: bool = False) -> None: """EIT Pathogena command line interface.""" - lib.check_for_newer_version() + tasks.check_for_newer_version() util.display_cli_version() configure_debug_logging(debug) @@ -40,15 +40,15 @@ def main(*, debug: bool = False) -> None: ) def auth(*, host: str | None = None, check_expiry: bool = False) -> None: """Authenticate with EIT Pathogena.""" - host = lib.get_host(host) + host = env.get_host(host) if check_expiry: - expiry = util.get_token_expiry(host) - if expiry and util.is_auth_token_live(host): + expiry = env.get_token_expiry(host) + if expiry and env.is_auth_token_live(host): logging.info(f"Current token for {host} expires at {expiry}") return else: logging.info(f"You do not have a valid token for {host}") - lib.authenticate(host=host) + tasks.authenticate(host=host) @main.command() @@ -63,8 +63,8 @@ def balance( host: str | None = None, ) -> None: """Check your EIT Pathogena account balance.""" - host = lib.get_host(host) - lib.get_credit_balance(host=host) + host = env.get_host(host) + tasks.fetch_credit_balance(host=host) @main.command() @@ -111,7 +111,7 @@ def decontaminate( """Decontaminate reads from provided csv samples.""" batch = models.create_batch_from_csv(input_csv, skip_fastq_check) batch.validate_all_sample_fastqs() - cleaned_batch_metadata = lib.decontaminate_samples_with_hostile( + cleaned_batch_metadata = tasks.decontaminate_samples_with_hostile( batch, threads, output_dir ) batch.update_sample_metadata(metadata=cleaned_batch_metadata) @@ -169,8 +169,8 @@ def upload( Creates a mapping CSV file which can be used to download output files with original sample names. """ - host = lib.get_host(host) - lib.check_version_compatibility(host=host) + host = env.get_host(host) + tasks.check_version_compatibility(host=host) if skip_fastq_check and skip_decontamination: logging.warning( "Cannot skip FastQ checks and decontamination due to metadata requirements for upload, continuing with" @@ -179,19 +179,19 @@ def upload( skip_fastq_check = False batch = models.create_batch_from_csv(upload_csv, skip_fastq_check) - if util.is_auth_token_live(host): - lib.get_credit_balance(host=host) - lib.validate_upload_permissions(batch, protocol=lib.get_protocol(), host=host) + if env.is_auth_token_live(host): + tasks.fetch_credit_balance(host=host) + tasks.validate_upload_permissions(batch, protocol=env.get_protocol(), host=host) if skip_decontamination: batch.validate_all_sample_fastqs() batch.update_sample_metadata() else: - cleaned_batch_metadata = lib.decontaminate_samples_with_hostile( + cleaned_batch_metadata = tasks.decontaminate_samples_with_hostile( batch, threads, output_dir=output_dir ) batch.update_sample_metadata(metadata=cleaned_batch_metadata) - lib.upload_batch(batch=batch, host=host, save=save) - lib.get_credit_balance(host=host) + tasks.upload_batch(batch=batch, save=save) + tasks.fetch_credit_balance(host=host) else: raise AuthorizationError() @@ -234,10 +234,10 @@ def download( That are created during upload. """ - host = lib.get_host(host) - if util.is_auth_token_live(host): + host = env.get_host(host) + if env.is_auth_token_live(host): if util.validate_guids(util.parse_comma_separated_string(samples)): - lib.download( + tasks.download( samples=samples, filenames=filenames, inputs=inputs, @@ -245,7 +245,7 @@ def download( host=host, ) elif Path(samples).is_file(): - lib.download( + tasks.download( mapping_csv=Path(samples), filenames=filenames, inputs=inputs, @@ -269,38 +269,40 @@ def query_raw(samples: str, *, host: str | None = None) -> None: SAMPLES should be command separated list of GUIDs or path to mapping CSV. """ - host = lib.get_host(host) + host = env.get_host(host) if util.validate_guids(util.parse_comma_separated_string(samples)): - result = lib.query(samples=samples, host=host) + result = tasks.fetch_sample_metadata(samples=samples, host=host) elif (sample_path := Path(samples)).is_file(): - result = lib.query(mapping_csv=sample_path, host=host) + result = tasks.fetch_sample_metadata(mapping_csv=sample_path, host=host) else: raise ValueError( f"{samples} is neither a valid mapping CSV path nor a comma-separated list of valid GUIDs" ) - print(json_.dumps(result, indent=4)) # noqa: T201 + print(json.dumps(result, indent=4)) # noqa: T201 @main.command() @click.argument("samples", type=str) @click.option("--json", is_flag=True, help="Output status in JSON format") @click.option("--host", type=str, default=None, help="API hostname (for development)") -def query_status(samples: str, *, json: bool = False, host: str | None = None) -> None: +def query_status( + samples: str, *, print_json: bool = False, host: str | None = None +) -> None: """Fetch processing status for one or more SAMPLES. SAMPLES should be command separated list of GUIDs or path to mapping CSV. """ - host = lib.get_host(host) + host = env.get_host(host) if util.validate_guids(util.parse_comma_separated_string(samples)): - result = lib.status(samples=samples, host=host) + result = tasks.fetch_sample_status(samples=samples, host=host) elif (sample_path := Path(samples)).is_file(): - result = lib.status(mapping_csv=sample_path, host=host) + result = tasks.fetch_sample_status(mapping_csv=sample_path, host=host) else: raise ValueError( f"{samples} is neither a valid mapping CSV path nor a comma-separated list of valid GUIDs" ) - if json: - print(json_.dumps(result, indent=4)) # noqa: T201 + if print_json: + print(json.dumps(result, indent=4)) # noqa: T201 else: for name, status in result.items(): print(f"{name} \t{status}") # noqa: T201 @@ -309,7 +311,7 @@ def query_status(samples: str, *, json: bool = False, host: str | None = None) - @main.command() def download_index() -> None: """Download and cache host decontamination index.""" - lib.download_index() + tasks.download_index() @main.command() @@ -319,10 +321,12 @@ def download_index() -> None: @click.option("--host", type=str, default=None, help="API hostname (for development)") def validate(upload_csv: Path, *, host: str | None = None) -> None: """Validate a given upload CSV.""" - host = lib.get_host(host) + host = env.get_host(host) batch = models.create_batch_from_csv(upload_csv) - lib.upload_batch(batch=batch, host=host, save=False, validate_only=True) - lib.validate_upload_permissions(batch=batch, protocol=lib.get_protocol(), host=host) + tasks.upload_batch(batch=batch, save=False, validate_only=True) + tasks.validate_upload_permissions( + batch=batch, protocol=env.get_protocol(), host=host + ) batch.validate_all_sample_fastqs() logging.info(f"Successfully validated {upload_csv}") @@ -352,27 +356,27 @@ def validate(upload_csv: Path, *, host: str | None = None) -> None: type=str, help="3-letter Country Code", required=True, - default=constants.DEFAULT_METADATA["country"], + default=constants.DEFAULT_COUNTRY, show_default=True, ) @click.option( "--instrument-platform", type=click.Choice(["illumina", "ont"]), - default=constants.DEFAULT_METADATA["instrument_platform"], + default=constants.DEFAULT_INSTRUMENTPLATFORM, help="Sequencing technology", ) @click.option( "--subdivision", type=str, help="Subdivision", - default=constants.DEFAULT_METADATA["subdivision"], + default=constants.DEFAULT_SUBDIVISION, show_default=True, ) @click.option( "--district", type=str, help="District", - default=constants.DEFAULT_METADATA["district"], + default=constants.DEFAULT_DISTRICT, show_default=True, ) @click.option( @@ -380,12 +384,12 @@ def validate(upload_csv: Path, *, host: str | None = None) -> None: "pipeline", type=click.Choice(["mycobacteria", "sars-cov-2"]), help="Specimen organism", - default=constants.DEFAULT_METADATA["pipeline"], + default=constants.DEFAULT_PIPELINE, show_default=True, ) @click.option( "--amplicon-scheme", - type=click.Choice(lib.get_amplicon_schemes()), + type=click.Choice(tasks.fetch_amplicon_schemes()), help="Amplicon scheme, use only when SARS-CoV-2 is the specimen organism", default=None, show_default=True, @@ -393,21 +397,21 @@ def validate(upload_csv: Path, *, host: str | None = None) -> None: @click.option( "--ont_read_suffix", type=str, - default=constants.DEFAULT_METADATA["ont_read_suffix"], + default=constants.DEFAULT_ONT_READ_SUFFIX, help="Read file ending for ONT fastq files", show_default=True, ) @click.option( "--illumina_read1_suffix", type=str, - default=constants.DEFAULT_METADATA["illumina_read1_suffix"], + default=constants.DEFAULT_ILLUMINA_READ1_SUFFIX, help="Read file ending for Illumina read 1 files", show_default=True, ) @click.option( "--illumina_read2_suffix", type=str, - default=constants.DEFAULT_METADATA["illumina_read2_suffix"], + default=constants.DEFAULT_ILLUMINA_READ2_SUFFIX, help="Read file ending for Illumina read 2 files", show_default=True, ) @@ -419,15 +423,15 @@ def build_csv( batch_name: str, collection_date: datetime, country: str, - subdivision: str = constants.DEFAULT_METADATA["subdivision"], - district: str = constants.DEFAULT_METADATA["district"], - pipeline: str = constants.DEFAULT_METADATA["pipeline"], + subdivision: str = constants.DEFAULT_SUBDIVISION, + district: str = constants.DEFAULT_DISTRICT, + pipeline: str = constants.DEFAULT_PIPELINE, amplicon_scheme: str | None = None, host_organism: str = "homo sapiens", - ont_read_suffix: str = constants.DEFAULT_METADATA["ont_read_suffix"], - illumina_read1_suffix: str = constants.DEFAULT_METADATA["illumina_read1_suffix"], - illumina_read2_suffix: str = constants.DEFAULT_METADATA["illumina_read2_suffix"], - max_batch_size: int = constants.DEFAULT_METADATA["max_batch_size"], + ont_read_suffix: str = constants.DEFAULT_ONT_READ_SUFFIX, + illumina_read1_suffix: str = constants.DEFAULT_ILLUMINA_READ1_SUFFIX, + illumina_read2_suffix: str = constants.DEFAULT_ILLUMINA_READ2_SUFFIX, + max_batch_size: int = constants.DEFAULT_MAX_BATCH_SIZE, ) -> None: r"""Command to create upload csv from SAMPLES_FOLDER containing sample fastqs. @@ -440,7 +444,7 @@ def build_csv( output_csv = Path(output_csv) samples_folder = Path(samples_folder) - upload_data = UploadData( + upload_data = models.UploadData( batch_name=batch_name, instrument_platform=instrument_platform, # type: ignore collection_date=collection_date, @@ -456,7 +460,7 @@ def build_csv( max_batch_size=max_batch_size, ) - build_upload_csv( + tasks.build_upload_csv( samples_folder, output_csv, upload_data, @@ -467,7 +471,7 @@ def build_csv( @click.option("--host", type=str, default=None, help="API hostname (for development)") def get_amplicon_schemes(*, host: str | None = None) -> None: """Get valid amplicon schemes from the server.""" - schemes = lib.get_amplicon_schemes(host=host) + schemes = tasks.fetch_amplicon_schemes(host=host) logging.info("Valid amplicon schemes:") for scheme in schemes: logging.info(scheme) diff --git a/src/pathogena/client/env.py b/src/pathogena/client/env.py new file mode 100644 index 0000000..3853ea6 --- /dev/null +++ b/src/pathogena/client/env.py @@ -0,0 +1,129 @@ +import json +import logging +import os +from datetime import datetime +from pathlib import Path + +from pathogena.constants import ( + DEFAULT_HOST, + DEFAULT_PROTOCOL, + DEFAULT_UPLOAD_HOST, +) + + +def get_protocol() -> str: + """Get the protocol to use for communication. + + Returns: + str: The protocol (e.g., 'http', 'https'). + """ + if "PATHOGENA_PROTOCOL" in os.environ: + return os.environ["PATHOGENA_PROTOCOL"] + else: + return DEFAULT_PROTOCOL + + +def get_host(cli_host: str | None = None) -> str: + """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. + + Args: + cli_host (str | None): The host provided via CLI argument. + + Returns: + str: The resolved hostname. + """ + return ( + cli_host + if cli_host is not None + else os.environ.get("PATHOGENA_HOST", DEFAULT_HOST) + ) + + +def get_upload_host(cli_host: str | None = None) -> str: + """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. + + Args: + cli_host (str | None): The host provided via CLI argument. + + Returns: + str: The resolved hostname. + """ + return ( + cli_host + if cli_host is not None + else os.environ.get("PATHOGENA_UPLOAD_HOST", DEFAULT_UPLOAD_HOST) + ) + + +def get_token_path(host: str) -> Path: + """Get the path to the token file for a given host. + + Args: + host (str): The host for which to get the token path. + + Returns: + Path: The path to the token file. + """ + conf_dir = Path.home() / ".config" / "pathogena" + token_dir = conf_dir / "tokens" + token_dir.mkdir(parents=True, exist_ok=True) + token_path = token_dir / f"{host}.json" + return token_path + + +def get_token_expiry(host: str) -> datetime | None: + """Get the expiry date of the token for a given host. + + Args: + host (str): The host for which to get the token expiry date. + + Returns: + datetime | None: The expiry date of the token, or None if the token does not exist. + """ + token_path = get_token_path(host) + if token_path.exists(): + try: + with open(token_path) as token_string: + token: dict = json.load(token_string) + expiry = token.get("expiry", False) + if expiry: + return datetime.fromisoformat(expiry) + except json.JSONDecodeError: + return None + return None + + +def is_auth_token_live(host: str) -> bool: + """Check if the authentication token for a given host is still valid. + + Args: + host (str): The host for which to check the token validity. + + Returns: + bool: True if the token is still valid, False otherwise. + """ + expiry = get_token_expiry(host) + if expiry: + logging.debug(f"Token expires: {expiry}") + return expiry > datetime.now() + return False + + +def get_access_token(host: str) -> str: + """Reads token from ~/.config/pathogena/tokens/. + + Args: + host (str): The host for which to retrieve the token. + + Returns: + str: The access token. + """ + token_path = get_token_path(host) + logging.debug(f"{token_path=}") + try: + data = json.loads(token_path.read_text()) + except FileNotFoundError as fne: + raise FileNotFoundError( + f"Token not found at {token_path}, have you authenticated?" + ) from fne + return data["access_token"].strip() diff --git a/src/pathogena/client/upload_client.py b/src/pathogena/client/upload_client.py new file mode 100644 index 0000000..bf42d03 --- /dev/null +++ b/src/pathogena/client/upload_client.py @@ -0,0 +1,357 @@ +import logging +import math +import sys +from itertools import chain +from typing import Any + +import httpx +from tenacity import retry, stop_after_attempt, wait_fixed + +from pathogena.client import env +from pathogena.constants import ( + DEFAULT_CHUNK_SIZE, +) +from pathogena.errors import APIError +from pathogena.types import ( + BatchUploadStatus, + PreparedFile, + Sample, + UploadingFile, +) + + +class UploadAPIClient: + """A class to handle API requests for batch uploads and related operations.""" + + base_url: str + client: httpx.Client + token: str + upload_session_id: int | None + + def __init__( + self, + base_url: str = env.get_upload_host(), + client: httpx.Client | None = None, + upload_session_id: int | None = None, + ): + """Initialize the APIClient with a base URL and an optional HTTP client. + + Args: + base_url (str): The base URL for the API, e.g api.upload-dev.eit-pathogena.com + client (httpx.Client | None): A custom HTTP client (Client) for making requests. + upload_session_id (int): The upload session id. + """ + self.base_url = base_url + self.client = client or httpx.Client() + self.token = env.get_access_token(env.get_host()) + self.upload_session_id = upload_session_id + + def create_batches( + self, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Creates a batch by making a POST request. + + Args: + data (dict[str, Any] | None): Data to include in the POST request body. + + Returns: + dict[str, Any]: The response JSON from the API. + + Raises: + APIError: If the API returns a non-2xx status code. + """ + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches" + response = httpx.Response(httpx.codes.OK) + try: + response = self.client.post( + url, + json=data, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPError as e: + raise APIError( + f"Failed to create: {response.text}", response.status_code + ) from e + + def start_file_upload( + self, + file: PreparedFile, + batch_id: str, + sample_id: str, + upload_session_id: int, + chunk_size: int = DEFAULT_CHUNK_SIZE, + ) -> UploadingFile: + """Wraps batches_uploads_start_file_upload which calls `start-file-upload`. + + Handles: + - creating the form to post data + - checking the response code + - logging and raising any API errors + + Args: + file (PreparedFile): The file being uploaded. + sample_id (str): The sample id for the file being uploaded. + batch_pk (str): The batch id for the file being uploaded. + upload_session_id (int): The upload session id. + chunk_size (int, optional): The size of the chunks for the file. Defaults to DEFAULT_CHUNK_SIZE. + + Raises: + APIError: If the response code is not 200. + + Returns: + UploadingFile: The PreparedFile plus data returned from `start-file-upload`. + """ + total_chunks = math.ceil(sys.getsizeof(file.data) / chunk_size) + + form_data = { + "original_file_name": file.name, + "total_chunks": total_chunks, + "content_type": file.content_type, + "sample_id": sample_id, + } + + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches/{batch_id}/uploads/start/" + try: + response = self.client.post( + url, + json=form_data, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + if response.status_code != 200: + raise APIError( + f"Failed to start batch upload: {response.text}", + response.status_code, + ) + start_file_upload_json = response.json() + return UploadingFile( + file_id=start_file_upload_json.get("sample_file_id"), + upload_id=start_file_upload_json.get("upload_id"), + batch_id=batch_id, + sample_id=start_file_upload_json.get("sample_id"), + total_chunks=total_chunks, + upload_session_id=upload_session_id, + prepared_file=file, + ) + except httpx.HTTPError as e: + raise APIError( + f"Failed to start batch upload: {response.text}", + response.status_code, + ) from e + + def end_file_upload( + self, + batch_pk: int, + data: dict[str, Any] | None = None, + ) -> httpx.Response: + """End a batch upload by making a POST request. + + Args: + batch_pk (int): The primary key of the batch. + data (dict[str, Any] | None): Data to include in the POST request body. + + Returns: + dict[str, Any]: The response JSON from the API. + + Raises: + APIError: If the API returns a non-2xx status code. + """ + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/uploads/end/" + try: + response = self.client.post( + url, + json=data, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + response.raise_for_status() + return response + except httpx.HTTPError as e: + raise APIError( + f"Failed to end batch upload: {response.text}", response.status_code + ) from e + + def end_upload_session( + self, + batch_pk: str, + upload_session_id: int | None = None, + ) -> httpx.Response: + """Ends a sample upload session by making a POST request to the backend. + + Args: + batch_pk (str): The primary key of the batch. + data (dict[str, Any] | None): Data to include in the POST request body. + + + Returns: + dict[str, Any]: The response JSON from the API. + + Raises: + APIError: If the API returns a non-2xx status code. + """ + if upload_session_id is not None: + data = {"upload_session": upload_session_id} + elif self.upload_session is not None: + data = {"upload_session": self.upload_session} + + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/sample-files/end-upload-session/" + try: + response = self.client.post( + url, + json=data, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + response.raise_for_status() # Raise an HTTPError for bad responses + return response + except httpx.HTTPError as e: + raise APIError( + f"Failed to end upload session: {response.text}", + response.status_code, + ) from e + + def start_upload_session( + self, batch_pk: str, prepared_samples: list[Sample[PreparedFile]] + ): + """Start upload session. + + Args: + batch_pk (int): The id for the batch being created. + prepared_samples (list[PreparedSample]): The list of prepared samples. + + Raises: + APIError: If the API returns a non-2xx status code. + """ + files = chain.from_iterable([sample.files for sample in prepared_samples]) + files_to_upload = [ + { + "original_file_name": file.name, + "file_size_in_kb": file.size, + "control": file.control, + "specimen_organism": file.specimen_organism, + } + for file in files + ] + + json_data = { + "files_to_upload": files_to_upload, + "specimen_organism": files_to_upload[0].get("specimen_organism"), + } + + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/sample-files/start-upload-session/" + try: + session_response = self.client.post( + url, + json=json_data, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + self.upload_session = session_response.json().get("upload_session") + if session_response.status_code != 200: + raise httpx.HTTPError("Session response status code was not 200") + + except httpx.HTTPError as e: + raise APIError( + f"Failed to start upload session: {session_response.text}", + session_response.status_code, + ) from e + + response_json = session_response.json() + if not response_json["upload_session"]: + # Log if the upload session could not be resumed + logging.exception( + "Upload session cannot be resumed. Please create a new batch." + ) + raise APIError( + "No upload session returned by the API.", + httpx.codes.INTERNAL_SERVER_ERROR, + ) + + upload_session_id = response_json["upload_session"] + upload_session_name = response_json["name"] + sample_summaries = response_json["sample_summaries"] + + return (upload_session_id, upload_session_name, sample_summaries) + + def get_batch_upload_status( + self, + batch_pk: str, + ) -> BatchUploadStatus: + """Starts an upload by making a POST request. + + Args: + batch_pk (int): The primary key of the batch. + data (dict[str, Any] | None): Data to include in the POST request body. + + Returns: + dict[str, Any]: The response JSON from the API. + + Raises: + APIError: If the API returns a non-2xx status code. + """ + url = f"{env.get_protocol()}://{self.base_url}/api/v1/batches/{batch_pk}/state" + try: + response = self.client.get( + url, + headers={"Authorization": f"Bearer {self.token}"}, + follow_redirects=True, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPError as e: + raise APIError( + f"Failed to fetch batch status: {response.text}", + response.status_code, + ) from e + + @retry(wait=wait_fixed(1), stop=stop_after_attempt(10)) + def upload_chunk( + self, + batch_pk: int, + protocol: str, + chunk: bytes, + chunk_index: int, + upload_id: str, + ) -> httpx.Response: + """Upload a single file chunk. + + Args: + batch_pk (int): ID of sample to upload + host (str): pathogena host, e.g api.upload-dev.eit-pathogena.com + protocol (str): protocol, default https + chunk (bytes): File chunk to be uploaded + chunk_index (int): Index representing what chunk of the whole + sample file this chunk is from 0...total_chunks + upload_id: the id of the upload session + + Returns: + Response: The response object from the HTTP POST request conatining + the status code and content from the server. + """ + try: + response = self.client.post( + f"{protocol}://{self.base_url}/api/v1/batches/{batch_pk}/uploads/upload-chunk/", + headers={"Authorization": f"Bearer {self.token}"}, + files={"chunk": chunk}, # Send the binary chunk + data={ + "chunk_index": chunk_index, + "upload_id": upload_id, + }, + follow_redirects=True, + ) + if response.status_code != 200: + raise Exception("Failed to upload chunk") + + return response + except Exception as e: + logging.error( + f"Exception while uploading chunk {chunk_index} of batch {batch_pk}: {str(e), chunk[:10]} RESPONSE {response.status_code, response.headers, response.content}" + ) + raise APIError( + f"Failed to upload chunk {chunk_index} of batch {batch_pk}: {str(e), chunk[:10]} RESPONSE {response.status_code, response.headers, response.content}", + response.status_code, + ) from e diff --git a/src/pathogena/constants.py b/src/pathogena/constants.py index a464cde..bb9dc87 100644 --- a/src/pathogena/constants.py +++ b/src/pathogena/constants.py @@ -1,26 +1,27 @@ import multiprocessing import os +from typing import Literal + +PLATFORMS = Literal["illumina", "ont"] CPU_COUNT = multiprocessing.cpu_count() + DEFAULT_HOST = "portal.eit-pathogena.com" DEFAULT_APP_HOST = "app.eit-pathogena.com" DEFAULT_UPLOAD_HOST = "api.upload.eit-pathogena.com" DEFAULT_PROTOCOL = "https" -DEFAULT_METADATA = { - "country": None, - "district": "", - "subdivision": "", - "instrument_platform": "illumina", - "pipeline": "mycobacteria", - "ont_read_suffix": ".fastq.gz", - "illumina_read1_suffix": "_1.fastq.gz", - "illumina_read2_suffix": "_2.fastq.gz", - "max_batch_size": 50, -} +DEFAULT_COUNTRY: None = None +DEFAULT_DISTRICT = "" +DEFAULT_SUBDIVISION = "" +DEFAULT_INSTRUMENTPLATFORM = "illumina" +DEFAULT_PIPELINE = "mycobacteria" +DEFAULT_ONT_READ_SUFFIX = ".fastq.gz" +DEFAULT_ILLUMINA_READ1_SUFFIX = "_1.fastq.gz" +DEFAULT_ILLUMINA_READ2_SUFFIX = "_2.fastq.gz" +DEFAULT_MAX_BATCH_SIZE = 50 + HOSTILE_INDEX_NAME = "human-t2t-hla-argos985-mycob140" DEFAULT_CHUNK_SIZE = int( os.getenv("NEXT_PUBLIC_CHUNK_SIZE", 10 * 1000 * 1000) ) # 10000000 = 10 mb -DEFAULT_MAX_UPLOAD_RETRIES = int(os.getenv("MAX_UPLOAD_RETRIES", 3)) -DEFAULT_RETRY_DELAY = int(os.getenv("RETRY_DELAY", 2)) diff --git a/src/pathogena/lib.py b/src/pathogena/lib.py deleted file mode 100644 index 96ae4bc..0000000 --- a/src/pathogena/lib.py +++ /dev/null @@ -1,908 +0,0 @@ -import csv -import json -import logging -import os -import shutil -from datetime import datetime, timedelta -from getpass import getpass -from json import JSONDecodeError -from pathlib import Path - -import hostile -import httpx -from hostile.lib import ALIGNER, clean_fastqs, clean_paired_fastqs -from hostile.util import BUCKET_URL, CACHE_DIR, choose_default_thread_count -from packaging.version import Version -from tenacity import retry, stop_after_attempt, wait_random_exponential -from tqdm import tqdm - -import pathogena -from pathogena import batch_upload_apis, models, upload_utils, util -from pathogena.constants import ( - CPU_COUNT, - DEFAULT_APP_HOST, - DEFAULT_HOST, - DEFAULT_PROTOCOL, - HOSTILE_INDEX_NAME, -) -from pathogena.errors import APIError, MissingError, UnsupportedClientError -from pathogena.log_utils import httpx_hooks -from pathogena.models import UploadBatch, UploadSample -from pathogena.upload_utils import ( - PreparedFiles, - UploadData, - get_upload_host, - prepare_files, -) -from pathogena.util import get_access_token, get_token_path - -logging.getLogger("httpx").setLevel(logging.WARNING) - - -def get_host(cli_host: str | None = None) -> str: - """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. - - Args: - cli_host (str | None): The host provided via CLI argument. - - Returns: - str: The resolved hostname. - """ - return ( - cli_host - if cli_host is not None - else os.environ.get("PATHOGENA_HOST", DEFAULT_HOST) - ) - - -def get_protocol() -> str: - """Get the protocol to use for communication. - - Returns: - str: The protocol (e.g., 'http', 'https'). - """ - if "PATHOGENA_PROTOCOL" in os.environ: - protocol = os.environ["PATHOGENA_PROTOCOL"] - return protocol - else: - return DEFAULT_PROTOCOL - - -def authenticate(host: str = DEFAULT_HOST) -> None: - """Requests a user auth token, writes to ~/.config/pathogena/tokens/.json. - - Args: - host (str): The host server. Defaults to DEFAULT_HOST. - """ - logging.info(f"Authenticating with {host}") - username = input("Enter your username: ") - password = getpass(prompt="Enter your password (hidden): ") - with httpx.Client(event_hooks=httpx_hooks) as client: - response = client.post( - f"{get_protocol()}://{host}/api/v1/auth/token", - json={"username": username, "password": password}, - follow_redirects=True, - ) - data = response.json() - - token_path = get_token_path(host) - - # Convert the expiry in seconds into a readable date, default token should be 7 days. - one_week_in_seconds = 604800 - expires_in = data.get("expires_in", one_week_in_seconds) - expiry = datetime.now() + timedelta(seconds=expires_in) - data["expiry"] = expiry.isoformat() - - with token_path.open(mode="w") as fh: - json.dump(data, fh) - logging.info(f"Authenticated ({token_path})") - - -def check_authentication(host: str) -> None: - """Check if the user is authenticated. - - Args: - host (str): The host server. - - Raises: - RuntimeError: If authentication fails. - """ - with httpx.Client(event_hooks=httpx_hooks): - response = httpx.get( - f"{get_protocol()}://{host}/api/v1/batches", - headers={"Authorization": f"Bearer {util.get_access_token(host)}"}, - follow_redirects=True, - ) - if response.is_error: - logging.error(f"Authentication failed for host {host}") - raise RuntimeError( - "Authentication failed. You may need to re-authenticate with `pathogena auth`" - ) - - -def get_amplicon_schemes(host: str | None = None) -> list[str]: - """Fetch valid amplicon schemes from the server. - - Returns: - list[str]: List of valid amplicon schemes. - """ - with httpx.Client(event_hooks=httpx_hooks): - response = httpx.get( - f"{get_protocol()}://{get_host(host)}/api/v1/amplicon_schemes", - ) - if response.is_error: - logging.error(f"Amplicon schemes could not be fetched from {get_host(host)}") - raise RuntimeError( - f"Amplicon schemes could not be fetched from the {get_host(host)}. Please try again later." - ) - return [val for val in response.json()["amplicon_schemes"] if val is not None] - - -def get_credit_balance(host: str) -> None: - """Get the credit balance for the user. - - Args: - host (str): The host server. - """ - logging.info(f"Getting credit balance for {host}") - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=15, - ) as client: - response = client.get( - f"{get_protocol()}://{host}/api/v1/credits/balance", - headers={"Authorization": f"Bearer {get_access_token(host)}"}, - follow_redirects=True, - ) - if response.status_code == 200: - logging.info(f"Your remaining account balance is {response.text} credits") - elif response.status_code == 402: - logging.error( - "Your account doesn't have enough credits to fulfil the number of Samples in your Batch." - ) - - -def create_batch_on_server( - batch: UploadBatch, - host: str, - amplicon_scheme: str | None, - validate_only: bool = False, -) -> tuple[str, str, str, str]: - """Create batch on server, return batch id. - - A transaction will be created at this point for the expected - total samples in the BatchModel. - - Args: - host (str): The host server. - number_of_samples (int): The expected number of samples in the batch. - amplicon_scheme (str | None): The amplicon scheme to use. - validate_only (bool): Whether to validate only. Defaults to False. - - Returns: - tuple[str, str]: The batch ID and name. - """ - # Assume every sample in batch has same collection date and country etc - instrument_platform = batch.samples[0].instrument_platform - collection_date = batch.samples[0].collection_date - country = batch.samples[0].country - telemetry_data = { - "client": { - "name": "pathogena-client", - "version": pathogena.__version__, - }, - "decontamination": { - "name": "hostile", - "version": hostile.__version__, - }, - "specimen_organism": batch.samples[0].specimen_organism, - } - - local_batch_name = ( - batch.samples[0].batch_name - if batch.samples[0].batch_name not in ["", " ", None] - else f"batch_{collection_date}" - ) - data = { - "collection_date": str(collection_date), - "instrument": instrument_platform, - "country": country, - "name": local_batch_name, - "amplicon_scheme": amplicon_scheme, - "telemetry_data": telemetry_data, - } - - url = f"{get_protocol()}://{host}/api/v1/batches" - if validate_only: - url += "/validate_creation" - try: - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=60, - follow_redirects=True, - ) as client: - batch_create_response = client.post( - f"{get_protocol()}://{get_upload_host()}/api/v1/batches/", - headers={ - "Authorization": f"Bearer {util.get_access_token(host)}", - "accept": "application/json", - }, - json=data, - follow_redirects=True, - ) - if validate_only: - # Don't attempt to return data if just validating (as there's none there) - return None, None, None, None # type: ignore - - created_batch = batch_create_response.json() - batch_id = created_batch["id"] - legacy_batch_id = created_batch["legacy_batch_id"] - - except JSONDecodeError: - logging.error( - f"Unable to communicate with the upload endpoint ({get_upload_host()}). Please check this has been set " - f"correctly and try again." - ) - exit(1) - except httpx.HTTPError as err: - raise APIError( - f"Failed to fetch batch status: {batch_create_response.text}", - batch_create_response.status_code, - ) from err - - # now make the legacy portal request to get the legacy batch name - try: - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=60, - follow_redirects=True, - ) as client: - legacy_batch_response = client.get( - f"{get_protocol()}://{get_host()}/api/v1/batches/{legacy_batch_id}", - headers={ - "Authorization": f"Bearer {util.get_access_token(host)}", - "accept": "application/json", - }, - follow_redirects=True, - ) - legacy_batch = legacy_batch_response.json() - - remote_batch_name = legacy_batch["name"] - return (batch_id, local_batch_name, remote_batch_name, legacy_batch_id) - except JSONDecodeError: - logging.error( - f"Unable to communicate with the legacy endpoint ({get_host()}). Please check this has been set " - f"correctly and try again." - ) - exit(1) - except httpx.HTTPError as err: - raise APIError( - f"Failed to fetch batch status: {legacy_batch_response.text}", - legacy_batch_response.status_code, - ) from err - - -def decontaminate_samples_with_hostile( - batch: models.UploadBatch, - threads: int, - output_dir: Path = Path("."), -) -> dict: - """Run Hostile to remove human reads from a given CSV file of FastQ files and return metadata related to the batch. - - Args: - batch (models.UploadBatch): The batch of samples to decontaminate. - threads (int): The number of threads to use. - output_dir (Path): The output directory for the cleaned FastQ files. - - Returns: - dict: Metadata related to the batch. - """ - logging.debug(f"decontaminate_samples_with_hostile() {threads=} {output_dir=}") - logging.info( - f"Removing human reads from {str(batch.instrument_platform).upper()} FastQ files and storing in {output_dir.absolute()}" - ) - fastq_paths = [] - decontamination_metadata = {} - if batch.is_ont(): - fastq_paths = [sample.reads_1_resolved_path for sample in batch.samples] - decontamination_metadata = clean_fastqs( - fastqs=fastq_paths, - index=HOSTILE_INDEX_NAME, - rename=True, - reorder=True, - threads=threads if threads else choose_default_thread_count(CPU_COUNT), - out_dir=output_dir, - force=True, - ) - elif batch.is_illumina(): - for sample in batch.samples: - fastq_paths.append( - (sample.reads_1_resolved_path, sample.reads_2_resolved_path) - ) - decontamination_metadata = clean_paired_fastqs( - fastqs=fastq_paths, - index=HOSTILE_INDEX_NAME, - rename=True, - reorder=True, - threads=threads if threads else choose_default_thread_count(CPU_COUNT), - out_dir=output_dir, - force=True, - aligner_args=" --local", - ) - batch_metadata = dict( - zip( - [s.sample_name for s in batch.samples], - decontamination_metadata, - strict=False, - ) - ) - batch.ran_through_hostile = True - logging.info( - f"Human reads removed from input samples and can be found here: {output_dir.absolute()}" - ) - return batch_metadata - - -def get_remote_sample_name( - sample: UploadSample, prepared_files: PreparedFiles -) -> (str, str): - """Get the remote names of the sample given the UploadSample object from the prepared files. - - Args: - sample (UploadSample): The sample for which to find the ID. - prepared_files (PreparedFiles): The prepared files containing resolved paths. - """ - for file in prepared_files["files"]: - resolved_path = file["file"]["resolved_path"] - if ( - sample.reads_1_resolved_path == resolved_path - or sample.reads_2_resolved_path == resolved_path - ): - return file["sample_id"] - raise ValueError( - f"Unable to determine sample ID for sample name {sample.sample_name}." - ) - - -def upload_batch( - batch: models.UploadBatch, - save: bool = False, - host: str = DEFAULT_HOST, - validate_only: bool = False, -) -> None: - """Upload a batch of samples. - - Args: - batch (models.UploadBatch): The batch of samples to upload. - save (bool): Whether to keep the files saved. - host (str): The host server. - validate_only (bool): Whether we should actually upload or just validate batch. - """ - batch_id, local_batch_name, remote_batch_name, legacy_batch_id = ( - create_batch_on_server( - batch=batch, - host=host, - amplicon_scheme=batch.samples[0].amplicon_scheme, - validate_only=validate_only, - ) - ) - if validate_only: - logging.info(f"Batch creation for {local_batch_name} validated successfully") - return - mapping_csv_records = [] - - prepared_files = prepare_files( - batch_pk=batch_id, - samples=batch.samples, - api_client=batch_upload_apis.UploadAPIClient(), - ) - - upload_file_type = UploadData( - access_token=util.get_access_token(get_host(None)), - batch_pk=batch_id, - env=get_upload_host(), - samples=batch.samples, - upload_session=prepared_files["uploadSession"], - ) - - for sample in batch.samples: - remote_sample_name = get_remote_sample_name( - sample=sample, prepared_files=prepared_files - ) - mapping_csv_records.append( - { - "batch_name": local_batch_name, - "sample_name": sample.sample_name, - "remote_sample_name": remote_sample_name, - "remote_batch_name": remote_batch_name, - "remote_batch_id": batch_id, - } - ) - util.write_csv(mapping_csv_records, f"{remote_batch_name}.mapping.csv") - logging.info(f"The mapping file {remote_batch_name}.mapping.csv has been created.") - logging.info( - "You can monitor the progress of your batch in EIT Pathogena here: " - f"{get_protocol()}://{os.environ.get('PATHOGENA_APP_HOST', DEFAULT_APP_HOST)}/batches/{legacy_batch_id}" - ) - - upload_utils.upload_fastq( - upload_data=upload_file_type, - prepared_files=prepared_files, - api_client=batch_upload_apis.UploadAPIClient(), - ) - - if not save: - for file in batch.samples: - remove_file(file_path=file.reads_1_upload_file) # type: ignore - if batch.is_illumina(): - remove_file(file_path=file.reads_2_upload_file) # type: ignore - logging.info( - f"Upload complete. Created {remote_batch_name}.mapping.csv (keep this safe)" - ) - - -def validate_upload_permissions(batch: UploadBatch, protocol: str, host: str) -> None: - """Perform pre-submission validation of a batch of sample model subsets. - - Args: - batch (UploadBatch): The batch to validate. - protocol (str): The protocol to use. - host (str): The host server. - """ - data = [] - for sample in batch.samples: - data.append( - { - "collection_date": str(sample.collection_date), - "country": sample.country, - "subdivision": sample.subdivision, - "district": sample.district, - "instrument_platform": sample.instrument_platform, - "specimen_organism": sample.specimen_organism, - } - ) - logging.debug(f"Validating {data=}") - headers = {"Authorization": f"Bearer {util.get_access_token(host)}"} - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=60, - ) as client: - response = client.post( - f"{protocol}://{host}/api/v1/batches/validate", - headers=headers, - json=data, - follow_redirects=True, - ) - logging.debug(f"{response.json()=}") - - -def fetch_sample(sample_id: str, host: str) -> dict: - """Fetch sample data from the server. - - Args: - sample_id (str): The sample ID. - host (str): The host server. - - Returns: - dict: The sample data. - """ - headers = {"Authorization": f"Bearer {util.get_access_token(host)}"} - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - ) as client: - response = client.get( - f"{get_protocol()}://{host}/api/v1/samples/{sample_id}", - headers=headers, - follow_redirects=True, - ) - return response.json() - - -def query( - samples: str | None = None, - mapping_csv: Path | None = None, - host: str = DEFAULT_HOST, -) -> dict[str, dict]: - """Query sample metadata returning a dict of metadata keyed by sample ID. - - Args: - query_string (str): The query string. - host (str): The host server. - protocol (str): The protocol to use. Defaults to DEFAULT_PROTOCOL. - - Returns: - dict: The query result. - """ - check_version_compatibility(host) - if samples: - guids = util.parse_comma_separated_string(samples) - guids_samples = dict.fromkeys(guids) - logging.info(f"Using guids {guids}") - elif mapping_csv: - csv_records = parse_csv(Path(mapping_csv)) - guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} - logging.info(f"Using samples in {mapping_csv}") - logging.debug(f"{guids_samples=}") - else: - raise RuntimeError("Specify either a list of sample IDs or a mapping CSV") - samples_metadata = {} - for guid, sample in tqdm( - guids_samples.items(), desc="Querying samples", leave=False - ): - name = sample if mapping_csv else guid - samples_metadata[name] = fetch_sample(sample_id=guid, host=host) - return samples_metadata - - -def status( - samples: str | None = None, - mapping_csv: Path | None = None, - host: str = DEFAULT_HOST, -) -> dict[str, str]: - """Get the status of samples from the server. - - Args: - samples (str | None): A comma-separated list of sample IDs. - mapping_csv (Path | None): The path to a CSV file containing sample mappings. - host (str): The host server. Defaults to DEFAULT_HOST. - - Returns: - dict[str, str]: A dictionary with sample IDs as keys and their statuses as values. - """ - check_version_compatibility(host) - if samples: - guids = util.parse_comma_separated_string(samples) - guids_samples = dict.fromkeys(guids) - logging.info(f"Using guids {guids}") - elif mapping_csv: - csv_records = parse_csv(Path(mapping_csv)) - guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} - logging.info(f"Using samples in {mapping_csv}") - logging.debug(guids_samples) - else: - raise RuntimeError("Specify either a list of sample IDs or a mapping CSV") - samples_status = {} - for guid, sample in tqdm( - guids_samples.items(), desc="Querying samples", leave=False - ): - name = sample if mapping_csv else guid - samples_status[name] = fetch_sample(sample_id=guid, host=host).get("status") - return samples_status - - -def fetch_latest_input_files(sample_id: str, host: str) -> dict[str, models.RemoteFile]: - """Return models.RemoteFile instances for a sample input files. - - Args: - sample_id (str): The sample ID. - host (str): The host server. - - Returns: - dict[str, models.RemoteFile]: The latest input files. - """ - headers = {"Authorization": f"Bearer {util.get_access_token(host)}"} - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - ) as client: - response = client.get( - f"{get_protocol()}://{host}/api/v1/samples/{sample_id}/latest/input-files", - headers=headers, - follow_redirects=True, - ) - data = response.json().get("files", []) - output_files = { - d["filename"]: models.RemoteFile( - filename=d["filename"], - sample_id=d["sample_id"], - run_id=d["run_id"], - ) - for d in data - } - logging.debug(f"{output_files=}") - return output_files - - -def fetch_output_files( - sample_id: str, host: str, latest: bool = True -) -> dict[str, models.RemoteFile]: - """Return models.RemoteFile instances for a sample, optionally including only latest run. - - Args: - sample_id (str): The sample ID. - host (str): The host server. - protocol (str): The protocol to use. Defaults to DEFAULT_PROTOCOL. - - Returns: - dict[str, models.RemoteFile]: The output files. - """ - headers = {"Authorization": f"Bearer {util.get_access_token(host)}"} - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - ) as client: - response = client.get( - f"{get_protocol()}://{host}/api/v1/samples/{sample_id}/latest/files", - headers=headers, - follow_redirects=True, - ) - data = response.json().get("files", []) - output_files = { - d["filename"]: models.RemoteFile( - filename=d["filename"].replace("_", ".", 1), - sample_id=d["sample_id"], - run_id=d["run_id"], - ) - for d in data - } - logging.debug(f"{output_files=}") - if latest: - max_run_id = max(output_file.run_id for output_file in output_files.values()) - output_files = {k: v for k, v in output_files.items() if v.run_id == max_run_id} - return output_files - - -def parse_csv(path: Path) -> list[dict]: - """Parse a CSV file. - - Args: - path (Path): The path to the CSV file. - - Returns: - list[dict]: The parsed CSV data. - """ - with open(path) as fh: - reader = csv.DictReader(fh) - return list(reader) - - -def check_version_compatibility(host: str) -> None: - """Check the client version expected by the server (Portal). - - Raise an exception if the client version is not - compatible. - - Args: - host (str): The host server. - """ - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=2), - timeout=10, - ) as client: - response = client.get( - f"{get_protocol()}://{host}/cli-version", follow_redirects=True - ) - lowest_cli_version = response.json()["version"] - logging.debug( - f"Client version {pathogena.__version__}, server version: {lowest_cli_version})" - ) - if Version(pathogena.__version__) < Version(lowest_cli_version): - raise UnsupportedClientError(pathogena.__version__, lowest_cli_version) - - -# noinspection PyBroadException -def check_for_newer_version() -> None: - """Check whether there is a new version of the CLI available on Pypi and advise the user to upgrade.""" - try: - pathogena_pypi_url = "https://pypi.org/pypi/pathogena/json" - with httpx.Client(transport=httpx.HTTPTransport(retries=2)) as client: - response = client.get( - pathogena_pypi_url, - headers={"Accept": "application/json"}, - follow_redirects=True, - ) - if response.status_code == 200: - latest_version = Version( - response.json() - .get("info", {}) - .get("version", pathogena.__version__) - ) - if Version(pathogena.__version__) < latest_version: - logging.info( - f"A new version of the EIT Pathogena CLI ({latest_version}) is available to install," - f" please follow the installation steps in the README.md file to upgrade." - ) - except (httpx.ConnectError, httpx.NetworkError, httpx.TimeoutException): - pass - except Exception: # Errors in this check should never prevent further CLI usage, ignore all errors. - pass - - -def download( - samples: str | None = None, - mapping_csv: Path | None = None, - filenames: str = "main_report.json", - inputs: bool = False, - out_dir: Path = Path("."), - rename: bool = True, - host: str = DEFAULT_HOST, -) -> None: - """Download the latest output files for a sample. - - Args: - samples (str | None): A comma-separated list of sample IDs. - mapping_csv (Path | None): The path to a CSV file containing sample mappings. - filenames (str): A comma-separated list of filenames to download. Defaults to "main_report.json". - inputs (bool): Whether to download input files as well. Defaults to False. - out_dir (Path): The directory to save the downloaded files. Defaults to the current directory. - rename (bool): Whether to rename the downloaded files based on the sample name. Defaults to True. - host (str): The host server. Defaults to DEFAULT_HOST. - """ - check_version_compatibility(host) - headers = {"Authorization": f"Bearer {util.get_access_token(host)}"} - if mapping_csv: - csv_records = parse_csv(Path(mapping_csv)) - guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} - logging.info(f"Using samples in {mapping_csv}") - logging.debug(guids_samples) - elif samples: - guids = util.parse_comma_separated_string(samples) - guids_samples = dict.fromkeys(guids) - logging.info(f"Using guids {guids}") - else: - raise RuntimeError("Specify either a list of samples or a mapping CSV") - unique_filenames: set[str] = util.parse_comma_separated_string(filenames) - for guid, sample in guids_samples.items(): - try: - output_files = fetch_output_files(sample_id=guid, host=host, latest=True) - except MissingError: - output_files = {} # There are no output files. The run may have failed. - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=7200, # 2 hours - ) as client: - for filename in unique_filenames: - prefixed_filename = f"{guid}_{filename}" - if prefixed_filename in output_files: - output_file = output_files[prefixed_filename] - url = ( - f"{get_protocol()}://{host}/api/v1/" - f"samples/{output_file.sample_id}/" - f"runs/{output_file.run_id}/" - f"files/{prefixed_filename}" - ) - if rename and mapping_csv: - filename_fmt = f"{sample}.{prefixed_filename.partition('_')[2]}" - else: - filename_fmt = output_file.filename - download_single( - client=client, - filename=filename_fmt, - url=url, - headers=headers, - out_dir=Path(out_dir), - ) - elif set( - filter(None, filenames) - ): # Skip case where filenames = set("") - logging.warning( - f"Skipped {sample if sample and rename else guid}.{filename}" - ) - if inputs: - input_files = fetch_latest_input_files(sample_id=guid, host=host) - for input_file in input_files.values(): - if rename and mapping_csv: - suffix = input_file.filename.partition(".")[2] - filename_fmt = f"{sample}.{suffix}" - else: - filename_fmt = input_file.filename - url = ( - f"{get_protocol()}://{host}/api/v1/" - f"samples/{input_file.sample_id}/" - f"runs/{input_file.run_id}/" - f"input-files/{input_file.filename}" - ) - download_single( - client=client, - filename=filename_fmt, - url=url, - headers=headers, - out_dir=Path(out_dir), - ) - - -@retry(wait=wait_random_exponential(multiplier=2, max=60), stop=stop_after_attempt(10)) -def download_single( - client: httpx.Client, - url: str, - filename: str, - headers: dict[str, str], - out_dir: Path, -) -> None: - """Download a single file from the server with retries. - - Args: - client (httpx.Client): The HTTP client to use for the request. - url (str): The URL of the file to download. - filename (str): The name of the file to save. - headers (dict[str, str]): The headers to include in the request. - out_dir (Path): The directory to save the downloaded file. - """ - logging.info(f"Downloading {filename}") - with client.stream("GET", url=url, headers=headers) as r: - file_size = int(r.headers.get("content-length", 0)) - chunk_size = 262_144 - with ( - Path(out_dir).joinpath(f"{filename}").open("wb") as fh, - tqdm( - total=file_size, - unit="B", - unit_scale=True, - desc=filename, - leave=False, # Works only if using a context manager - position=0, # Avoids leaving line break with leave=False - ) as progress, - ): - for data in r.iter_bytes(chunk_size): - fh.write(data) - progress.update(len(data)) - logging.debug(f"Downloaded {filename}") - - -def download_index(name: str = HOSTILE_INDEX_NAME) -> None: - """Download and cache the host decontamination index. - - Args: - name (str): The name of the index. Defaults to HOSTILE_INDEX_NAME. - """ - logging.info(f"Cache directory: {CACHE_DIR}") - logging.info(f"Manifest URL: {BUCKET_URL}/manifest.json") - ALIGNER.minimap2.value.check_index(name) - ALIGNER.bowtie2.value.check_index(name) - - -def prepare_upload_files( - target_filepath: Path, sample_id: str, read_num: int, decontaminated: bool = False -) -> Path: - """Rename the files to be compatible with what the server is expecting. - - Which is `*_{1,2}.fastq.gz` and - gzip the file if it isn't already, - which should only be if the files haven't been run through Hostile. - - Args: - target_filepath (Path): The target file path. - sample_id (str): The sample ID. - read_num (int): The read number. - decontaminated (bool): Whether the files are decontaminated. - - Returns: - Path: The prepared file path. - """ - new_reads_filename = f"{sample_id}_{read_num}.fastq.gz" - if decontaminated: - upload_filepath = target_filepath.rename( - target_filepath.with_name(new_reads_filename) - ) - else: - if target_filepath.suffix != ".gz": - upload_filepath = util.gzip_file(target_filepath, new_reads_filename) - else: - upload_filepath = shutil.copyfile( - target_filepath, target_filepath.with_name(new_reads_filename) - ) - return upload_filepath - - -def remove_file(file_path: Path) -> None: - """Remove a file from the filesystem. - - Args: - file_path (Path): The path to the file to remove. - """ - try: - file_path.unlink() - except OSError: - logging.error( - f"Failed to delete upload files created during execution, " - f"files may still be in {file_path.parent}" - ) - except Exception: - pass # A failure here doesn't matter since upload is complete diff --git a/src/pathogena/log_utils.py b/src/pathogena/log_utils.py index 3ab804e..485fde5 100644 --- a/src/pathogena/log_utils.py +++ b/src/pathogena/log_utils.py @@ -1,5 +1,6 @@ import logging import sys +from collections.abc import Callable, Mapping from types import TracebackType import httpx @@ -28,7 +29,9 @@ def configure_debug_logging(debug: bool) -> None: def exception_handler( - exception_type: type[BaseException], exception: Exception, _traceback: TracebackType + exception_type: type[BaseException], + exception: BaseException, + _traceback: TracebackType | None, ) -> None: """Handle uncaught exceptions by logging them. @@ -90,4 +93,7 @@ def raise_for_status(response: httpx.Response) -> None: response.raise_for_status() -httpx_hooks = {"request": [log_request], "response": [log_response, raise_for_status]} +httpx_hooks: Mapping[str, list[Callable]] = { + "request": [log_request], + "response": [log_response, raise_for_status], +} diff --git a/src/pathogena/models.py b/src/pathogena/models.py index a780f60..68856b4 100644 --- a/src/pathogena/models.py +++ b/src/pathogena/models.py @@ -5,17 +5,21 @@ from pydantic import BaseModel, Field, model_validator -from pathogena import __version__, util -from pathogena.util import find_duplicate_entries +from pathogena import __version__ +from pathogena.constants import PLATFORMS +from pathogena.util import ( + count_lines_in_gzip, + find_duplicate_entries, + hash_file, + parse_csv, + reads_lines_from_fastq, +) ALLOWED_EXTENSIONS = (".fastq", ".fq", ".fastq.gz", ".fq.gz") def is_valid_file_extension( filename: str, - allowed_extensions: tuple[ - Literal[".fastq"], Literal[".fq"], Literal[".fastq.gz"], Literal[".fq.gz"] - ] = ALLOWED_EXTENSIONS, ) -> bool: """Check if the file has a valid extension. @@ -26,7 +30,7 @@ def is_valid_file_extension( Returns: bool: True if the file has a valid extension, False otherwise. """ - return filename.endswith(allowed_extensions) + return filename.endswith(ALLOWED_EXTENSIONS) class UploadBase(BaseModel): @@ -35,9 +39,7 @@ class UploadBase(BaseModel): batch_name: str | None = Field( default=None, description="Batch name (anonymised prior to upload)" ) - instrument_platform: util.PLATFORMS = Field( - description="Sequencing instrument platform" - ) + instrument_platform: PLATFORMS = Field(description="Sequencing instrument platform") collection_date: date = Field(description="Collection date in yyyy-mm-dd format") country: str = Field( min_length=3, max_length=3, description="ISO 3166-2 alpha-3 country code" @@ -45,7 +47,7 @@ class UploadBase(BaseModel): subdivision: str | None = Field( default=None, description="ISO 3166-2 principal subdivision" ) - district: str = Field(default=None, description="Granular location") + district: str = Field(default="", description="Granular location") specimen_organism: Literal["mycobacteria", "sars-cov-2"] = Field( default="mycobacteria", description="Target specimen organism scientific name" ) @@ -58,6 +60,23 @@ class UploadBase(BaseModel): ) +class UploadData(UploadBase): + """Model for upload data with additional fields for read suffixes and batch size.""" + + ont_read_suffix: str = Field( + default=".fastq.gz", description="Suffix for ONT reads" + ) + illumina_read1_suffix: str = Field( + default="_1.fastq.gz", description="Suffix for Illumina reads (first of pair)" + ) + illumina_read2_suffix: str = Field( + default="_2.fastq.gz", description="Suffix for Illumina reads (second of pair)" + ) + max_batch_size: int = Field( + default=50, description="Maximum number of samples per batch" + ) + + class UploadSample(UploadBase): """Model for an uploaded sample's data.""" @@ -210,9 +229,9 @@ def validate_reads_from_fastq(self) -> None: for read in reads: logging.info(f"Calculating read count in: {read}") if read.suffix == ".gz": - line_count = util.reads_lines_from_gzip(file_path=read) + line_count = count_lines_in_gzip(file_path=read) else: - line_count = util.reads_lines_from_fastq(file_path=read) + line_count = reads_lines_from_fastq(file_path=read) if line_count % valid_lines_per_read != 0: raise ValueError( f"FASTQ file {read.name} does not have a multiple of 4 lines" @@ -230,13 +249,13 @@ def get_read_paths(self) -> list[Path]: case None, None: return [] case x, None: - return [x] + return [x] # type: ignore case None, x: - return [x] + return [x] # type: ignore case x, y if self.is_illumina(): - return [x, y] + return [x, y] # type: ignore case x, y if self.is_ont(): # ont only one file - return [x] + return [x] # type: ignore case _: return [] @@ -256,6 +275,18 @@ def is_illumina(self) -> bool: """ return self.instrument_platform == "illumina" + def read_file1_data(self): + """Read the contents of the first fastq file.""" + if self.reads_1_resolved_path: + with open(self.reads_1_resolved_path, "rb") as file: + return file.read() + + def read_file2_data(self): + """Read the contents of the second fastq file.""" + if self.reads_2_resolved_path: + with open(self.reads_2_resolved_path, "rb") as file: + return file.read() + class UploadBatch(BaseModel): """Model for a batch of upload samples.""" @@ -372,7 +403,7 @@ def validate_no_amplicon_scheme_myco(self): ) return self - def update_sample_metadata(self, metadata: dict[str, Any] = None) -> None: + def update_sample_metadata(self, metadata: dict[str, Any] | None = None) -> None: """Updates the sample metadata. Update sample metadata with output from decontamination process, or defaults if @@ -391,29 +422,25 @@ def update_sample_metadata(self, metadata: dict[str, Any] = None) -> None: ) # Assume no change in default if sample.reads_1_resolved_path is not None: - sample.reads_1_dirty_checksum = util.hash_file( - sample.reads_1_resolved_path - ) + sample.reads_1_dirty_checksum = hash_file(sample.reads_1_resolved_path) else: sample.reads_1_dirty_checksum = "" if self.ran_through_hostile: sample.reads_1_cleaned_path = Path( cleaned_sample_data.get("fastq1_out_path") ) - sample.reads_1_pre_upload_checksum = util.hash_file( + sample.reads_1_pre_upload_checksum = hash_file( sample.reads_1_cleaned_path ) else: sample.reads_1_pre_upload_checksum = sample.reads_1_dirty_checksum if sample.is_illumina() and sample.reads_2_resolved_path: - sample.reads_2_dirty_checksum = util.hash_file( - sample.reads_2_resolved_path - ) + sample.reads_2_dirty_checksum = hash_file(sample.reads_2_resolved_path) if self.ran_through_hostile: sample.reads_2_cleaned_path = Path( cleaned_sample_data.get("fastq2_out_path") ) - sample.reads_2_pre_upload_checksum = util.hash_file( + sample.reads_2_pre_upload_checksum = hash_file( sample.reads_2_cleaned_path ) else: @@ -464,7 +491,7 @@ def create_batch_from_csv(upload_csv: Path, skip_checks: bool = False) -> Upload Returns: UploadBatch: The created UploadBatch instance. """ - records = util.parse_csv(upload_csv) + records = parse_csv(upload_csv) samples = [UploadSample(**r, **{"upload_csv": upload_csv}) for r in records] specimen_organism = samples[0].specimen_organism if len(samples) > 0 else None diff --git a/src/pathogena/tasks/__init__.py b/src/pathogena/tasks/__init__.py new file mode 100644 index 0000000..024a2fa --- /dev/null +++ b/src/pathogena/tasks/__init__.py @@ -0,0 +1,65 @@ +"""Init file containing all tasks that can be run from the CLI interface.""" + +from pathogena.tasks.authentication import authenticate, check_authentication +from pathogena.tasks.download import ( + download, + download_index, + download_single, + fetch_latest_input_files, +) +from pathogena.tasks.prep_samples import ( + build_upload_csv, + decontaminate_samples_with_hostile, + validate_upload_permissions, +) +from pathogena.tasks.query import ( + check_for_newer_version, + check_version_compatibility, + fetch_amplicon_schemes, + fetch_credit_balance, + fetch_output_files, + fetch_sample, + fetch_sample_metadata, + fetch_sample_status, + parse_csv, +) +from pathogena.tasks.upload import ( + create_batch_on_server, + prepare_sample, + prepare_upload_files, + remove_file, + start_upload_session, + upload_batch, + upload_chunks, + upload_fastq_files, +) + +__all__ = [ + "fetch_sample_metadata", + "fetch_sample_status", + "parse_csv", + "check_version_compatibility", + "check_for_newer_version", + "fetch_output_files", + "fetch_amplicon_schemes", + "fetch_credit_balance", + "authenticate", + "check_authentication", + "download", + "download_single", + "download_index", + "fetch_latest_input_files", + "fetch_latest_input_files", + "fetch_sample", + "prepare_upload_files", + "upload_batch", + "create_batch_on_server", + "upload_fastq_files", + "upload_chunks", + "start_upload_session", + "prepare_sample", + "remove_file", + "build_upload_csv", + "decontaminate_samples_with_hostile", + "validate_upload_permissions", +] diff --git a/src/pathogena/tasks/authentication.py b/src/pathogena/tasks/authentication.py new file mode 100644 index 0000000..e1ab68a --- /dev/null +++ b/src/pathogena/tasks/authentication.py @@ -0,0 +1,62 @@ +import json +import logging +from datetime import datetime, timedelta +from getpass import getpass + +import httpx + +from pathogena.client import env +from pathogena.constants import DEFAULT_HOST +from pathogena.log_utils import httpx_hooks + + +def authenticate(host: str = DEFAULT_HOST) -> None: + """Requests a user auth token, writes to ~/.config/pathogena/tokens/.json. + + Args: + host (str): The host server. Defaults to DEFAULT_HOST. + """ + logging.info(f"Authenticating with {host}") + username = input("Enter your username: ") + password = getpass(prompt="Enter your password (hidden): ") + with httpx.Client(event_hooks=httpx_hooks) as client: + response = client.post( + f"{env.get_protocol()}://{host}/api/v1/auth/token", + json={"username": username, "password": password}, + follow_redirects=True, + ) + data = response.json() + + token_path = env.get_token_path(host) + + # Convert the expiry in seconds into a readable date, default token should be 7 days. + one_week_in_seconds = 604800 + expires_in = data.get("expires_in", one_week_in_seconds) + expiry = datetime.now() + timedelta(seconds=expires_in) + data["expiry"] = expiry.isoformat() + + with token_path.open(mode="w") as fh: + json.dump(data, fh) + logging.info(f"Authenticated ({token_path})") + + +def check_authentication(host: str) -> None: + """Check if the user is authenticated. + + Args: + host (str): The host server. + + Raises: + RuntimeError: If authentication fails. + """ + with httpx.Client(event_hooks=httpx_hooks): + response = httpx.get( + f"{env.get_protocol()}://{host}/api/v1/batches", + headers={"Authorization": f"Bearer {env.get_access_token(host)}"}, + follow_redirects=True, + ) + if response.is_error: + logging.error(f"Authentication failed for host {host}") + raise RuntimeError( + "Authentication failed. You may need to re-authenticate with `pathogena auth`" + ) diff --git a/src/pathogena/tasks/download.py b/src/pathogena/tasks/download.py new file mode 100644 index 0000000..08574b3 --- /dev/null +++ b/src/pathogena/tasks/download.py @@ -0,0 +1,196 @@ +import logging +from pathlib import Path + +import httpx +import tqdm +from hostile.lib import ALIGNER +from hostile.util import BUCKET_URL, CACHE_DIR +from tenacity import retry, stop_after_attempt, wait_random_exponential + +from pathogena import models, util +from pathogena.client import env +from pathogena.constants import DEFAULT_HOST, HOSTILE_INDEX_NAME +from pathogena.errors import MissingError +from pathogena.log_utils import httpx_hooks +from pathogena.tasks.query import ( + check_version_compatibility, + fetch_output_files, + parse_csv, +) + + +def download( + samples: str | None = None, + mapping_csv: Path | None = None, + filenames: str = "main_report.json", + inputs: bool = False, + out_dir: Path = Path("."), + rename: bool = True, + host: str = DEFAULT_HOST, +) -> None: + """Download the latest output files for a sample. + + Args: + samples (str | None): A comma-separated list of sample IDs. + mapping_csv (Path | None): The path to a CSV file containing sample mappings. + filenames (str): A comma-separated list of filenames to download. Defaults to "main_report.json". + inputs (bool): Whether to download input files as well. Defaults to False. + out_dir (Path): The directory to save the downloaded files. Defaults to the current directory. + rename (bool): Whether to rename the downloaded files based on the sample name. Defaults to True. + host (str): The host server. Defaults to DEFAULT_HOST. + """ + check_version_compatibility(host) + headers = {"Authorization": f"Bearer {env.get_access_token(host)}"} + if mapping_csv: + csv_records = parse_csv(Path(mapping_csv)) + guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} + logging.info(f"Using samples in {mapping_csv}") + logging.debug(guids_samples) + elif samples: + guids = util.parse_comma_separated_string(samples) + guids_samples = dict.fromkeys(guids) + logging.info(f"Using guids {guids}") + else: + raise RuntimeError("Specify either a list of samples or a mapping CSV") + unique_filenames: set[str] = util.parse_comma_separated_string(filenames) + for guid, sample in guids_samples.items(): + try: + output_files = fetch_output_files(sample_id=guid, host=host, latest=True) + except MissingError: + output_files = {} # There are no output files. The run may have failed. + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + timeout=7200, # 2 hours + ) as client: + for filename in unique_filenames: + prefixed_filename = f"{guid}_{filename}" + if prefixed_filename in output_files: + output_file = output_files[prefixed_filename] + url = ( + f"{env.get_protocol()}://{host}/api/v1/" + f"samples/{output_file.sample_id}/" + f"runs/{output_file.run_id}/" + f"files/{prefixed_filename}" + ) + if rename and mapping_csv: + filename_fmt = f"{sample}.{prefixed_filename.partition('_')[2]}" + else: + filename_fmt = output_file.filename + download_single( + client=client, + filename=filename_fmt, + url=url, + headers=headers, + out_dir=Path(out_dir), + ) + elif set( + filter(None, filenames) + ): # Skip case where filenames = set("") + logging.warning( + f"Skipped {sample if sample and rename else guid}.{filename}" + ) + if inputs: + input_files = fetch_latest_input_files(sample_id=guid, host=host) + for input_file in input_files.values(): + if rename and mapping_csv: + suffix = input_file.filename.partition(".")[2] + filename_fmt = f"{sample}.{suffix}" + else: + filename_fmt = input_file.filename + url = ( + f"{env.get_protocol()}://{host}/api/v1/" + f"samples/{input_file.sample_id}/" + f"runs/{input_file.run_id}/" + f"input-files/{input_file.filename}" + ) + download_single( + client=client, + filename=filename_fmt, + url=url, + headers=headers, + out_dir=Path(out_dir), + ) + + +@retry(wait=wait_random_exponential(multiplier=2, max=60), stop=stop_after_attempt(10)) +def download_single( + client: httpx.Client, + url: str, + filename: str, + headers: dict[str, str], + out_dir: Path, +) -> None: + """Download a single file from the server with retries. + + Args: + client (httpx.Client): The HTTP client to use for the request. + url (str): The URL of the file to download. + filename (str): The name of the file to save. + headers (dict[str, str]): The headers to include in the request. + out_dir (Path): The directory to save the downloaded file. + """ + logging.info(f"Downloading {filename}") + with client.stream("GET", url=url, headers=headers) as r: + file_size = int(r.headers.get("content-length", 0)) + chunk_size = 262_144 + with ( + Path(out_dir).joinpath(f"{filename}").open("wb") as fh, + tqdm( + total=file_size, + unit="B", + unit_scale=True, + desc=filename, + leave=False, # Works only if using a context manager + position=0, # Avoids leaving line break with leave=False + ) as progress, + ): + for data in r.iter_bytes(chunk_size): + fh.write(data) + progress.update(len(data)) + logging.debug(f"Downloaded {filename}") + + +def download_index(name: str = HOSTILE_INDEX_NAME) -> None: + """Download and cache the host decontamination index. + + Args: + name (str): The name of the index. Defaults to HOSTILE_INDEX_NAME. + """ + logging.info(f"Cache directory: {CACHE_DIR}") + logging.info(f"Manifest URL: {BUCKET_URL}/manifest.json") + ALIGNER.minimap2.value.check_index(name) + ALIGNER.bowtie2.value.check_index(name) + + +def fetch_latest_input_files(sample_id: str, host: str) -> dict[str, models.RemoteFile]: + """Return models.RemoteFile instances for a sample input files. + + Args: + sample_id (str): The sample ID. + host (str): The host server. + + Returns: + dict[str, models.RemoteFile]: The latest input files. + """ + headers = {"Authorization": f"Bearer {env.get_access_token(host)}"} + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + ) as client: + response = client.get( + f"{env.get_protocol()}://{host}/api/v1/samples/{sample_id}/latest/input-files", + headers=headers, + follow_redirects=True, + ) + data = response.json().get("files", []) + output_files = { + d["filename"]: models.RemoteFile( + filename=d["filename"], + sample_id=d["sample_id"], + run_id=d["run_id"], + ) + for d in data + } + logging.debug(f"{output_files=}") + return output_files diff --git a/src/pathogena/create_upload_csv.py b/src/pathogena/tasks/prep_samples.py similarity index 59% rename from src/pathogena/create_upload_csv.py rename to src/pathogena/tasks/prep_samples.py index 2df0c76..9c938b2 100644 --- a/src/pathogena/create_upload_csv.py +++ b/src/pathogena/tasks/prep_samples.py @@ -2,26 +2,114 @@ import logging from pathlib import Path -from pydantic import Field +import httpx +from hostile.lib import clean_fastqs, clean_paired_fastqs +from hostile.util import choose_default_thread_count -from pathogena.models import UploadBase +from pathogena import models +from pathogena.client import env +from pathogena.constants import ( + CPU_COUNT, + HOSTILE_INDEX_NAME, +) +from pathogena.log_utils import httpx_hooks +from pathogena.models import UploadBatch, UploadData +logging.getLogger("httpx").setLevel(logging.WARNING) -class UploadData(UploadBase): - """Model for upload data with additional fields for read suffixes and batch size.""" - ont_read_suffix: str = Field( - default=".fastq.gz", description="Suffix for ONT reads" - ) - illumina_read1_suffix: str = Field( - default="_1.fastq.gz", description="Suffix for Illumina reads (first of pair)" +def decontaminate_samples_with_hostile( + batch: models.UploadBatch, + threads: int, + output_dir: Path = Path("."), +) -> dict: + """Run Hostile to remove human reads from a given CSV file of FastQ files and return metadata related to the batch. + + Args: + batch (models.UploadBatch): The batch of samples to decontaminate. + threads (int): The number of threads to use. + output_dir (Path): The output directory for the cleaned FastQ files. + + Returns: + dict: Metadata related to the batch. + """ + logging.debug(f"decontaminate_samples_with_hostile() {threads=} {output_dir=}") + logging.info( + f"Removing human reads from {str(batch.instrument_platform).upper()} FastQ files and storing in {output_dir.absolute()}" ) - illumina_read2_suffix: str = Field( - default="_2.fastq.gz", description="Suffix for Illumina reads (second of pair)" + decontamination_metadata = {} + if batch.is_ont(): + decontamination_metadata = clean_fastqs( + fastqs=[sample.reads_1_resolved_path for sample in batch.samples], + index=HOSTILE_INDEX_NAME, + rename=True, + reorder=True, + threads=threads if threads else choose_default_thread_count(CPU_COUNT), + out_dir=output_dir, + force=True, + ) + elif batch.is_illumina(): + decontamination_metadata = clean_paired_fastqs( + fastqs=[ + (sample.reads_1_resolved_path, sample.reads_2_resolved_path) + for sample in batch.samples + ], + index=HOSTILE_INDEX_NAME, + rename=True, + reorder=True, + threads=threads if threads else choose_default_thread_count(CPU_COUNT), + out_dir=output_dir, + force=True, + aligner_args=" --local", + ) + batch_metadata = dict( + zip( + [s.sample_name for s in batch.samples], + decontamination_metadata, + strict=False, + ) ) - max_batch_size: int = Field( - default=50, description="Maximum number of samples per batch" + batch.ran_through_hostile = True + logging.info( + f"Human reads removed from input samples and can be found here: {output_dir.absolute()}" ) + return batch_metadata + + +def validate_upload_permissions(batch: UploadBatch, protocol: str, host: str) -> None: + """Perform pre-submission validation of a batch of sample model subsets. + + Args: + batch (UploadBatch): The batch to validate. + protocol (str): The protocol to use. + host (str): The host server. + """ + data = [] + for sample in batch.samples: + data.append( + { + "collection_date": str(sample.collection_date), + "country": sample.country, + "subdivision": sample.subdivision, + "district": sample.district, + "instrument_platform": sample.instrument_platform, + "specimen_organism": sample.specimen_organism, + } + ) + logging.debug(f"Validating {data=}") + headers = {"Authorization": f"Bearer {env.get_access_token(host)}"} + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + timeout=60, + ) as client: + response = client.post( + f"{protocol}://{host}/api/v1/batches/validate", + headers=headers, + json=data, + follow_redirects=True, + ) + logging.debug(f"{response.json()=}") def build_upload_csv( @@ -76,7 +164,8 @@ def build_upload_csv( raise ValueError("Invalid instrument platform") if ( - upload_data.specimen_organism + UploadData.model_fields["specimen_organism"].annotation + and upload_data.specimen_organism not in UploadData.model_fields["specimen_organism"].annotation.__args__ ): raise ValueError("Invalid pipeline") diff --git a/src/pathogena/tasks/query.py b/src/pathogena/tasks/query.py new file mode 100644 index 0000000..6be21d0 --- /dev/null +++ b/src/pathogena/tasks/query.py @@ -0,0 +1,260 @@ +import csv +import logging +from pathlib import Path + +import httpx +import tqdm +from packaging.version import Version + +import pathogena +from pathogena import models, util +from pathogena.client import env +from pathogena.constants import DEFAULT_HOST +from pathogena.errors import UnsupportedClientError +from pathogena.log_utils import httpx_hooks + + +def parse_csv(path: Path) -> list[dict]: + """Parse a CSV file. + + Args: + path (Path): The path to the CSV file. + + Returns: + list[dict]: The parsed CSV data. + """ + with open(path) as fh: + reader = csv.DictReader(fh) + return list(reader) + + +def check_version_compatibility(host: str) -> None: + """Check the client version expected by the server (Portal). + + Raise an exception if the client version is not + compatible. + + Args: + host (str): The host server. + """ + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=2), + timeout=10, + ) as client: + response = client.get( + f"{env.get_protocol()}://{host}/cli-version", follow_redirects=True + ) + lowest_cli_version = response.json()["version"] + logging.debug( + f"Client version {pathogena.__version__}, server version: {lowest_cli_version})" + ) + if Version(pathogena.__version__) < Version(lowest_cli_version): + raise UnsupportedClientError(pathogena.__version__, lowest_cli_version) + + +# noinspection PyBroadException +def check_for_newer_version() -> None: + """Check whether there is a new version of the CLI available on Pypi and advise the user to upgrade.""" + try: + pathogena_pypi_url = "https://pypi.org/pypi/pathogena/json" + with httpx.Client(transport=httpx.HTTPTransport(retries=2)) as client: + response = client.get( + pathogena_pypi_url, + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + if response.status_code == 200: + latest_version = Version( + response.json() + .get("info", {}) + .get("version", pathogena.__version__) + ) + if Version(pathogena.__version__) < latest_version: + logging.info( + f"A new version of the EIT Pathogena CLI ({latest_version}) is available to install," + f" please follow the installation steps in the README.md file to upgrade." + ) + except (httpx.ConnectError, httpx.NetworkError, httpx.TimeoutException): + pass + except Exception: # Errors in this check should never prevent further CLI usage, ignore all errors. + pass + + +def fetch_sample_status( + samples: str | None = None, + mapping_csv: Path | None = None, + host: str = DEFAULT_HOST, +) -> dict[str, str]: + """Get the status of samples from the server. + + Args: + samples (str | None): A comma-separated list of sample IDs. + mapping_csv (Path | None): The path to a CSV file containing sample mappings. + host (str): The host server. Defaults to DEFAULT_HOST. + + Returns: + dict[str, str]: A dictionary with sample IDs as keys and their statuses as values. + """ + check_version_compatibility(host) + if samples: + guids = util.parse_comma_separated_string(samples) + guids_samples = dict.fromkeys(guids) + logging.info(f"Using guids {guids}") + elif mapping_csv: + csv_records = parse_csv(Path(mapping_csv)) + guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} + logging.info(f"Using samples in {mapping_csv}") + logging.debug(guids_samples) + else: + raise RuntimeError("Specify either a list of sample IDs or a mapping CSV") + samples_status = {} + for guid, sample in tqdm( + guids_samples.items(), desc="Querying samples", leave=False + ): + name = sample if mapping_csv else guid + samples_status[name] = fetch_sample(sample_id=guid, host=host).get("status") + return samples_status # type: ignore + + +def fetch_sample_metadata( + samples: str | None = None, + mapping_csv: Path | None = None, + host: str = DEFAULT_HOST, +) -> dict[str, dict]: + """Query sample metadata returning a dict of metadata keyed by sample ID. + + Args: + query_string (str): The query string. + host (str): The host server. + protocol (str): The protocol to use. Defaults to DEFAULT_PROTOCOL. + + Returns: + dict: The query result. + """ + check_version_compatibility(host) + if samples: + guids = util.parse_comma_separated_string(samples) + guids_samples = dict.fromkeys(guids) + logging.info(f"Using guids {guids}") + elif mapping_csv: + csv_records = parse_csv(Path(mapping_csv)) + guids_samples = {s["remote_sample_name"]: s["sample_name"] for s in csv_records} + logging.info(f"Using samples in {mapping_csv}") + logging.debug(f"{guids_samples=}") + else: + raise RuntimeError("Specify either a list of sample IDs or a mapping CSV") + samples_metadata = {} + for guid, sample in tqdm( + guids_samples.items(), desc="Querying samples", leave=False + ): + name = sample if mapping_csv else guid + samples_metadata[name] = fetch_sample(sample_id=guid, host=host) + return samples_metadata + + +def fetch_output_files( + sample_id: str, host: str, latest: bool = True +) -> dict[str, models.RemoteFile]: + """Return models.RemoteFile instances for a sample, optionally including only latest run. + + Args: + sample_id (str): The sample ID. + host (str): The host server. + protocol (str): The protocol to use. Defaults to DEFAULT_PROTOCOL. + + Returns: + dict[str, models.RemoteFile]: The output files. + """ + headers = {"Authorization": f"Bearer {env.get_access_token(host)}"} + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + ) as client: + response = client.get( + f"{env.get_protocol()}://{host}/api/v1/samples/{sample_id}/latest/files", + headers=headers, + follow_redirects=True, + ) + data = response.json().get("files", []) + output_files = { + d["filename"]: models.RemoteFile( + filename=d["filename"].replace("_", ".", 1), + sample_id=d["sample_id"], + run_id=d["run_id"], + ) + for d in data + } + logging.debug(f"{output_files=}") + if latest: + max_run_id = max(output_file.run_id for output_file in output_files.values()) + output_files = {k: v for k, v in output_files.items() if v.run_id == max_run_id} + return output_files + + +def fetch_amplicon_schemes(host: str | None = None) -> list[str]: + """Fetch valid amplicon schemes from the server. + + Returns: + list[str]: List of valid amplicon schemes. + """ + with httpx.Client(event_hooks=httpx_hooks): + response = httpx.get( + f"{env.get_protocol()}://{env.get_host(host)}/api/v1/amplicon_schemes", + ) + if response.is_error: + logging.error( + f"Amplicon schemes could not be fetched from {env.get_host(host)}" + ) + raise RuntimeError( + f"Amplicon schemes could not be fetched from the {env.get_host(host)}. Please try again later." + ) + return [val for val in response.json()["amplicon_schemes"] if val is not None] + + +def fetch_credit_balance(host: str) -> None: + """Get the credit balance for the user. + + Args: + host (str): The host server. + """ + logging.info(f"Getting credit balance for {host}") + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + timeout=15, + ) as client: + response = client.get( + f"{env.get_protocol()}://{host}/api/v1/credits/balance", + headers={"Authorization": f"Bearer {env.get_access_token(host)}"}, + follow_redirects=True, + ) + if response.status_code == 200: + logging.info(f"Your remaining account balance is {response.text} credits") + elif response.status_code == 402: + logging.error( + "Your account doesn't have enough credits to fulfil the number of Samples in your Batch." + ) + + +def fetch_sample(sample_id: str, host: str) -> dict: + """Fetch sample data from the server. + + Args: + sample_id (str): The sample ID. + host (str): The host server. + + Returns: + dict: The sample data. + """ + headers = {"Authorization": f"Bearer {env.get_access_token(host)}"} + with httpx.Client( + event_hooks=httpx_hooks, + transport=httpx.HTTPTransport(retries=5), + ) as client: + response = client.get( + f"{env.get_protocol()}://{host}/api/v1/samples/{sample_id}", + headers=headers, + follow_redirects=True, + ) + return response.json() diff --git a/src/pathogena/tasks/upload.py b/src/pathogena/tasks/upload.py new file mode 100644 index 0000000..c09207e --- /dev/null +++ b/src/pathogena/tasks/upload.py @@ -0,0 +1,452 @@ +import logging +import os +import shutil +import time +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor, as_completed +from json import JSONDecodeError +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import hostile + +import pathogena +from pathogena import models, util +from pathogena.client import env +from pathogena.client.upload_client import UploadAPIClient +from pathogena.constants import ( + DEFAULT_APP_HOST, + DEFAULT_CHUNK_SIZE, +) +from pathogena.types import ( + OnComplete, + OnProgress, + PreparedFile, + Sample, + UploadData, + UploadingFile, + UploadSession, +) + +if TYPE_CHECKING: + from httpx import Response + + +def prepare_upload_files( + target_filepath: Path, sample_id: str, read_num: int, decontaminated: bool = False +) -> Path: + """Rename the files to be compatible with what the server is expecting. + + Which is `*_{1,2}.fastq.gz` and + gzip the file if it isn't already, + which should only be if the files haven't been run through Hostile. + + Args: + target_filepath (Path): The target file path. + sample_id (str): The sample ID. + read_num (int): The read number. + decontaminated (bool): Whether the files are decontaminated. + + Returns: + Path: The prepared file path. + """ + new_reads_filename = f"{sample_id}_{read_num}.fastq.gz" + if decontaminated: + upload_filepath = target_filepath.rename( + target_filepath.with_name(new_reads_filename) + ) + else: + if target_filepath.suffix != ".gz": + upload_filepath = util.gzip_file(target_filepath, new_reads_filename) + else: + upload_filepath = shutil.copyfile( + target_filepath, target_filepath.with_name(new_reads_filename) + ) + return upload_filepath + + +def create_batch_on_server( + batch: models.UploadBatch, + amplicon_scheme: str | None, + validate_only: bool = False, +) -> tuple[str, str, str]: + """Create batch on server, return batch id. + + A transaction will be created at this point for the expected + total samples in the BatchModel. + + Args: + host (str): The host server. + number_of_samples (int): The expected number of samples in the batch. + amplicon_scheme (str | None): The amplicon scheme to use. + validate_only (bool): Whether to validate only. Defaults to False. + + Returns: + tuple[str, str]: The batch ID and name. + """ + client = UploadAPIClient() + + # Assume every sample in batch has same collection date and country etc + instrument_platform = batch.samples[0].instrument_platform + collection_date = batch.samples[0].collection_date + country = batch.samples[0].country + telemetry_data = { + "client": { + "name": "pathogena-client", + "version": pathogena.__version__, + }, + "decontamination": { + "name": "hostile", + "version": hostile.__version__, + }, + "specimen_organism": batch.samples[0].specimen_organism, + } + + batch_name = ( + batch.samples[0].batch_name + if batch.samples[0].batch_name not in ["", " ", None] + else f"batch_{collection_date}" + ) + data = { + "collection_date": str(collection_date), + "instrument": instrument_platform, + "country": country, + "name": batch_name, + "amplicon_scheme": amplicon_scheme, + "telemetry_data": telemetry_data, + } + + try: + response = client.create_batches(data) + if validate_only: + # Don't attempt to return data if just validating (as there's none there) + return None, None, None # type: ignore + return ( + response["id"], + response["name"], + response["legacy_batch_id"], + ) + except JSONDecodeError: + logging.error( + f"Unable to communicate with the upload endpoint ({client.base_url}). Please check this has been set " + f"correctly and try again." + ) + exit(1) + + +def upload_batch( + batch: models.UploadBatch, + save: bool = False, + validate_only: bool = False, +) -> None: + """Upload a batch of samples. + + Args: + batch (models.UploadBatch): The batch of samples to upload. + save (bool): Whether to keep the files saved. + host (str): The host server. + validate_only (bool): Whether we should actually upload or just validate batch. + """ + client = UploadAPIClient() + + batch_id, batch_name, legacy_batch_id = create_batch_on_server( + batch=batch, + amplicon_scheme=batch.samples[0].amplicon_scheme, + validate_only=validate_only, + ) + if validate_only: + logging.info(f"Batch creation for {batch_name} validated successfully") + return + + upload_session = start_upload_session( + batch_pk=batch_id, samples=batch.samples, api_client=client + ) + + upload_file_type = UploadData( + access_token=env.get_access_token(env.get_host(None)), + batch_pk=batch_id, + env=env.get_upload_host(), + samples=batch.samples, + upload_session_id=upload_session.session_id, + ) + + mapping_csv_records = [] + + for sample in upload_session.samples: + for file in sample.files: + mapping_csv_records.append( + { + "batch_name": upload_session.name, + "sample_name": file.prepared_file.name, + "remote_sample_name": file.sample_id, + "remote_batch_name": batch_name, + "remote_batch_id": batch_id, + } + ) + + util.write_csv(mapping_csv_records, f"{batch_name}.mapping.csv") + logging.info(f"The mapping file {batch_name}.mapping.csv has been created.") + logging.info( + "You can monitor the progress of your batch in EIT Pathogena here: " + f"{env.get_protocol()}://{os.environ.get('PATHOGENA_APP_HOST', DEFAULT_APP_HOST)}/batches/{legacy_batch_id}" + ) + + upload_fastq_files( + client, upload_data=upload_file_type, upload_session=upload_session + ) + + if not save: + for sample in upload_session.samples: + for file in sample.files: + if file.prepared_file.path: + remove_file(file_path=file.prepared_file.path) + + logging.info(f"Upload complete. Created {batch_name}.mapping.csv (keep this safe)") + + +def upload_fastq_files( + client: UploadAPIClient, + upload_data: UploadData, + upload_session: UploadSession, +) -> None: + """Uploads files in chunks and manages the upload process. + + This function first prepares the files for upload, then uploads them in chunks + using a thread pool executor for concurrent uploads. It finishes by ending the + upload session. + + Args: + upload_data (UploadData): An object containing the upload configuration, + including the batch ID, access token, environment, and file details. + prepared_files (PreparedFiles): Set of files together with all the metadata needed for upload. + api_client (UploadAPIClient): Instance of the APIClient class. + + Returns: + None + """ + client = UploadAPIClient() + # upload the file chunks + with ThreadPoolExecutor(max_workers=upload_data.max_concurrent_chunks) as executor: + futures = [] + for sample in upload_session.samples: + for file in sample.files: + future = executor.submit(upload_chunks, client, upload_data, file) + futures.append(future) + + # Need to tie halves of the samples together here + # And call end sample when a sample is finished uploading + for future in as_completed(futures): + try: + future.result() + except Exception as e: + logging.error(f"Error uploading file: {e}") + + # end the upload session + end_session = client.end_upload_session( + upload_data.batch_pk, upload_session_id=upload_session.session_id + ) + + if end_session.status_code != 200: + logging.error(f"Failed to end upload session for batch {upload_data.batch_pk}.") + else: + logging.info(f"All uploads complete.") + + +def upload_chunks( + client: UploadAPIClient, + upload_data: UploadData, + file: UploadingFile, + chunk_size: int = DEFAULT_CHUNK_SIZE, +) -> None: + """Uploads chunks of a single file. + + Args: + client (UploadAPIClient): The upload API client to use. + upload_data (UploadData): The upload data including batch_id, session info, etc. + file (SelectedFile): The file to upload (with file data, total chunks, etc.) + chunk_size (int): Default size of file chunk to upload (5mb) + + Returns: + None: This function does not return anything, but calls the provided + `on_progress` and `on_complete` callback functions. + """ + chunks_uploaded = 0 + chunk_queue: list[Response] = [] + + for i in range(file.total_chunks): # total chunks = file.size/chunk_size + process_queue(chunk_queue, upload_data.max_concurrent_chunks) + + # chunk the files + start = i * chunk_size # 5 MB chunk size default + end = start + chunk_size + file_chunk = file.prepared_file.data[start:end] + + chunk_upload = client.upload_chunk( + batch_pk=upload_data.batch_pk, + protocol=env.get_protocol(), + chunk=file_chunk, + chunk_index=i, + upload_id=file.upload_id, + ) + chunk_queue.append(chunk_upload) + try: + chunk_upload_result = chunk_upload.json() + + # process result of chunk upload for upload chunks that don't return 400 status + metrics = chunk_upload_result.get("metrics", {}) + if metrics: + chunks_uploaded += 1 + progress = (chunks_uploaded / file.total_chunks) * 100 + + # Create an OnProgress instance + progress_event = OnProgress( + upload_id=file.upload_id, + batch_pk=upload_data.batch_pk, + progress=progress, + metrics=chunk_upload_result["metrics"], + ) + upload_data.on_progress = progress_event + + # If all chunks have been uploaded, complete the file upload + if chunks_uploaded == file.total_chunks: + complete_event = OnComplete(file.upload_id, upload_data.batch_pk) + upload_data.on_complete = complete_event + client = UploadAPIClient() + end_status = client.end_file_upload( + upload_data.batch_pk, + data={"upload_id": file.upload_id}, + ) + if end_status.status_code == 400: + logging.error( + f"Failed to end upload for file: {file.upload_id} (Batch ID: {upload_data.batch_pk})" + ) + + except Exception as e: + logging.error( + f"Error uploading chunk {i} for file: {file.upload_id} of batch {upload_data.batch_pk}: {str(e)}" + ) + + +def process_queue(chunk_queue: list, max_concurrent_chunks: int) -> Generator[Any]: + """Processes a queue of chunks concurrently to ensure tno more than 'max_concurrent_chunks' are processed at the same time. + + Args: + chunk_queue (list): A collection of futures (generated by thread pool executor) + representing the chunks to be processed. + max_concurrent_chunks (int): The maximum number of chunks to be processed concurrently. + """ + if len(chunk_queue) >= max_concurrent_chunks: + completed = [] + for future in as_completed(chunk_queue): + yield future.result() + completed.append(future) + for future in completed: # remove completed futures from queue + chunk_queue.remove(future) + + +def start_upload_session( + batch_pk: str, + samples: list[models.UploadSample], + api_client: UploadAPIClient, +) -> UploadSession: + """Prepares multiple files for upload. + + This function starts the upload session, + then starts the upload files for each file + and returns the bundle. + + Args: + batch_pk (str): The ID of the batch. + samples (list[UploadSample]): List of samples to prepare the files for. + api_client (UploadAPIClient): Instance of the APIClient class. + + Returns: + UploadSession: Upload session id, name and samples. + """ + batch_instrument_is_illumina = samples[0].is_illumina() + + prepared_samples: list[Sample[PreparedFile]] = [ + prepare_sample(sample) for sample in samples + ] + + # Call start upload session endpoint + upload_session_id, upload_session_name, sample_summaries = ( + api_client.start_upload_session(batch_pk, prepared_samples) + ) + + if batch_instrument_is_illumina: + # Duplicate the summaries for each half of the files + per_file_sample_summaries = [ + item for item in sample_summaries for _ in range(2) + ] + else: + per_file_sample_summaries = sample_summaries + + # Call start upload file endpoint for each file + index = 0 + uploading_samples: list[Sample[UploadingFile]] = [] + for unprepared_sample in prepared_samples: + uploading_sample_files: list[UploadingFile] = [] + for file in unprepared_sample.files: + sample_id = per_file_sample_summaries[index].get("sample_id") + uploading_file = api_client.start_file_upload( + file, batch_pk, sample_id, upload_session_id + ) + uploading_sample_files.append(uploading_file) + index += 1 + + uploading_samples.append( + Sample[UploadingFile]( + unprepared_sample.instrument_platform, uploading_sample_files + ) + ) + + # Return the bundle of start upload session and start file upload responses + return UploadSession( + session_id=upload_session_id, + name=upload_session_name, + samples=uploading_samples, + ) + + +def prepare_sample(sample: models.UploadSample) -> Sample[PreparedFile]: + """Prepares a samples' file for upload. + + This function starts the upload session, checks the upload status of the current + sample and if it has not already been uploaded or partially uploaded prepares + the sample from scratch. + + Args: + sample (UploadSample): The upload sample. + + Returns: + SelectedSample: Prepared sample. + """ + if sample.is_illumina(): + sample_files = [ + PreparedFile(upload_sample=sample, file_side=1), + PreparedFile(upload_sample=sample, file_side=2), + ] + else: + sample_files = [PreparedFile(upload_sample=sample, file_side=1)] + + return Sample[PreparedFile]( + instrument_platform=sample.instrument_platform, files=sample_files + ) + + +def remove_file(file_path: Path) -> None: + """Remove a file from the filesystem. + + Args: + file_path (Path): The path to the file to remove. + """ + try: + file_path.unlink() + except OSError: + logging.error( + f"Failed to delete upload files created during execution, " + f"files may still be in {file_path.parent}" + ) + except Exception: + pass # A failure here doesn't matter since upload is complete diff --git a/src/pathogena/types.py b/src/pathogena/types.py new file mode 100644 index 0000000..30f9d1b --- /dev/null +++ b/src/pathogena/types.py @@ -0,0 +1,273 @@ +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Generic, Literal, TypedDict, TypeVar + +from pathogena.constants import PLATFORMS +from pathogena.models import UploadSample + + +class PreparedFile: + """A file which is prepared for upload (pre `start-file-upload` call).""" + + name: str | None + size: int + path: Path | None + control: str + content_type: str + specimen_organism: Literal["mycobacteria", "sars-cov-2", ""] + data: bytes + + def __init__(self, upload_sample: UploadSample, file_side: Literal[1, 2]): + if file_side == 1: + path = upload_sample.reads_1_resolved_path + size = upload_sample.file1_size + data = upload_sample.read_file1_data() + elif file_side == 2: + path = upload_sample.reads_2_resolved_path + size = upload_sample.file2_size + data = upload_sample.read_file2_data() + + self.name = path.name if path else None + self.size = size + self.data = data + self.path = path + self.control = upload_sample.control.upper() + self.content_type = ( + "application/gzip" + if path is not None and path.suffix in ("gzip", "gz") + else "text/plain" + ) + self.specimen_organism = upload_sample.specimen_organism + + +@dataclass +class UploadingFile: + """A file which is being uploaded (post `start-file-upload` call).""" + + id: int + upload_id: str + sample_id: str + batch_id: str + upload_session_id: int + + prepared_file: PreparedFile + + status: Literal["IN_PROGRESS", "COMPLETE", "FAILED"] + total_chunks: int + + def __init__( + self, + file_id: int, + upload_id: str, + sample_id: str, + batch_id: str, + upload_session_id: int, + total_chunks: int, + prepared_file: PreparedFile, + status="IN_PROGRESS", + ): + self.id = file_id + self.upload_id = upload_id + self.sample_id = sample_id + self.batch_id = batch_id + + self.prepared_file = prepared_file + self.total_chunks = total_chunks + self.upload_session_id = upload_session_id + self.status = status + + +FileType = TypeVar("FileType") + + +@dataclass +class Sample(Generic[FileType]): + """A TypedDict representing a sample. + + Args: + instrument_platform: the instrument used to create the sample files (illumina | ont) + files: list of files in the sample + """ + + instrument_platform: PLATFORMS + files: list[FileType] + + +@dataclass +class UploadSession: + """All the information for an UploadSession. + + Including the response data from + - `start-upload-session` + + And the per-file-calls to + - `start-file-upload` + + """ + + session_id: int + name: str + samples: list[Sample[UploadingFile]] + + +class SampleFileMetadata(TypedDict): + """A TypedDict representing metadata for a file upload. + + Args: + name: The name of the sample file + size: The size of the sample file in bytes + content_type: The content type + specimen_organism: The organism from which the sample was taken + """ + + name: str + size: int + content_type: str + specimen_organism: str + resolved_path: Path | None + control: str + + +class UploadMetrics(TypedDict): + """A TypedDict representing metrics for file upload progress and status. + + Args: + chunks_received: Number of chunks successfully received by the server + chunks_total: Total number of chunks expected for the complete file + upload_status: Current status of the upload (e.g. "in_progress", "complete") + percentage_complete: Upload progress as a percentage from 0 to 100 + upload_speed: Current upload speed in bytes per second + time_remaining: Estimated time remaining for upload completion in seconds + estimated_completion_time: Predicted datetime when upload will complete + """ + + chunks_received: int + chunks_total: int + upload_status: str + percentage_complete: float + upload_speed: float + time_remaining: float + estimated_completion_time: datetime + + +class SampleFileUploadStatus(TypedDict): + """A TypedDict representing the status and metadata of a sample file upload. + + Args: + id: Unique identifier for the sample file + batch: ID of the batch this sample belongs to + file_path: Path to the uploaded file on the server + uploaded_file_name: Original name of the uploaded file + generated_name: System-generated name for the file + created_at: Timestamp when the upload was created + upload_status: Current status of the upload (IN_PROGRESS/COMPLETE/FAILED) + total_chunks: Total number of chunks for this file + upload_id: Unique identifier for this upload session -- check this comment + legacy_sample_id: Original sample ID from legacy system + metrics: Upload metrics including progress and performance data + """ + + id: int + batch: int + file_path: str + uploaded_file_name: str + generated_name: str + created_at: datetime + upload_status: Literal["IN_PROGRESS", "COMPLETE", "FAILED"] + total_chunks: int + upload_id: str + legacy_sample_id: str + metrics: UploadMetrics + + +class BatchUploadStatus(TypedDict): + """A TypedDict representing the status of a batch upload and its sample files. + + Args: + upload_status: Current status of the batch upload (e.g. "in_progress", "complete") + sample_files: Dictionary mapping sample file IDs to their individual upload statuses + """ + + upload_status: str + sample_files: dict[str, SampleFileUploadStatus] + + +@dataclass +class Metrics: + """A placeholder class for the metrics associated with file uploads.""" + + ... + + +@dataclass +class OnProgress: + """Initializes the OnProgress instance. + + Args: + upload_id (str): The ID of the uploading file. + batch_pk (int): The batch ID associated with the file upload. + progress (float): The percentage of upload completion. + metrics (UploadMetrics): The metrics associated with the upload. + """ + + upload_id: str + batch_pk: int + progress: float + metrics: UploadMetrics + + +@dataclass +class OnComplete: + """Initializes the OnComplete instance. + + Args: + upload_id (str): The ID of the uploading file. + batch_pk (int): The batch ID associated with the file upload. + """ + + upload_id: str + batch_pk: int + + +@dataclass +class UploadData: + """A class representing the parameters related to uploading files.""" + + def __init__( + self, + access_token, + batch_pk, + env, + samples: list[UploadSample], + on_complete: OnComplete | None = None, + on_progress: OnProgress | None = None, + max_concurrent_chunks: int = 5, + max_concurrent_files: int = 3, + upload_session_id=None, + abort_controller=None, + ): + """Initializes the UploadFileType instance. + + Args: + access_token (str): The access token for authentication. + batch_pk (str): The batch ID for the upload. + env (str): The environment for the upload endpoint. + samples (list[UploadSample]): A list of samples to upload. Defaults to an empty list. + on_complete (Callable[[OnComplete], None]): A callback function to call when the upload is complete. + on_progress (Callable[[OnProgress], None]): A callback function to call during the upload progress. + max_concurrent_chunks (int): The maximum number of chunks to upload concurrently. Defaults to 5. + max_concurrent_files (int): The maximum number of files to upload concurrently. Defaults to 3. + upload_session (int | None): The upload session ID. + abort_controller (Any | None): An optional controller to abort the upload. + """ + self.access_token = access_token + self.batch_pk = batch_pk + self.env = env + self.samples = samples + self.on_complete = on_complete + self.on_progress = on_progress + self.max_concurrent_chunks = max_concurrent_chunks + self.max_concurrent_files = max_concurrent_files + self.upload_session_id = upload_session_id + self.abort_controller = abort_controller diff --git a/src/pathogena/upload_utils.py b/src/pathogena/upload_utils.py deleted file mode 100644 index 251e98d..0000000 --- a/src/pathogena/upload_utils.py +++ /dev/null @@ -1,821 +0,0 @@ -import logging -import math -import os -import sys -import time -from collections.abc import Generator -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any, Literal, TypedDict - -import httpx -from httpx import Response, codes -from tenacity import retry, stop_after_attempt, wait_random_exponential - -from pathogena.batch_upload_apis import APIError, UploadAPIClient -from pathogena.constants import ( - DEFAULT_CHUNK_SIZE, - DEFAULT_HOST, - DEFAULT_MAX_UPLOAD_RETRIES, - DEFAULT_PROTOCOL, - DEFAULT_RETRY_DELAY, - DEFAULT_UPLOAD_HOST, -) -from pathogena.log_utils import httpx_hooks -from pathogena.models import UploadSample -from pathogena.util import get_access_token - - -def get_protocol() -> str: - """Get the protocol to use for communication. - - Returns: - str: The protocol (e.g., 'http', 'https'). - """ - protocol = os.environ.get("PATHOGENA_PROTOCOL") - if protocol is not None: - return protocol - else: - return DEFAULT_PROTOCOL - - -def get_host(cli_host: str | None = None) -> str: - """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. - - Args: - cli_host (str | None): The host provided via CLI argument. - - Returns: - str: The resolved hostname. - """ - return ( - cli_host - if cli_host is not None - else os.environ.get("PATHOGENA_HOST", DEFAULT_HOST) - ) - - -class SampleFileMetadata(TypedDict): - """A TypedDict representing metadata for a file upload. - - Args: - name: The name of the sample file - size: The size of the sample file in bytes - content_type: The content type - specimen_organism: The organism from which the sample was taken - """ - - name: str - size: int - content_type: str - specimen_organism: str - resolved_path: Path | None - control: str - - -class UploadMetrics(TypedDict): - """A TypedDict representing metrics for file upload progress and status. - - Args: - chunks_received: Number of chunks successfully received by the server - chunks_total: Total number of chunks expected for the complete file - upload_status: Current status of the upload (e.g. "in_progress", "complete") - percentage_complete: Upload progress as a percentage from 0 to 100 - upload_speed: Current upload speed in bytes per second - time_remaining: Estimated time remaining for upload completion in seconds - estimated_completion_time: Predicted datetime when upload will complete - """ - - chunks_received: int - chunks_total: int - upload_status: str - percentage_complete: float - upload_speed: float - time_remaining: float - estimated_completion_time: datetime - - -class SampleFileUploadStatus(TypedDict): - """A TypedDict representing the status and metadata of a sample file upload. - - Args: - id: Unique identifier for the sample file - batch: ID of the batch this sample belongs to - file_path: Path to the uploaded file on the server - uploaded_file_name: Original name of the uploaded file - generated_name: System-generated name for the file - created_at: Timestamp when the upload was created - upload_status: Current status of the upload (IN_PROGRESS/COMPLETE/FAILED) - total_chunks: Total number of chunks for this file - upload_id: Unique identifier for this upload session - legacy_sample_id: Original sample ID from legacy system - metrics: Upload metrics including progress and performance data - """ - - id: int - batch: int - file_path: str - uploaded_file_name: str - generated_name: str - created_at: datetime - upload_status: Literal["IN_PROGRESS", "COMPLETE", "FAILED"] - total_chunks: int - upload_id: str - legacy_sample_id: str - metrics: UploadMetrics - - -class BatchUploadStatus(TypedDict): - """A TypedDict representing the status of a batch upload and its sample files. - - Args: - upload_status: Current status of the batch upload (e.g. "in_progress", "complete") - sample_files: Dictionary mapping sample file IDs to their individual upload statuses - """ - - upload_status: str - sample_files: dict[str, SampleFileUploadStatus] - - -class SelectedFile(TypedDict): - """A TypedDict representing a file selected for upload with its metadata. - - Args: - file: Dictionary containing file information with string keys and values - upload_id: Unique identifier for the upload - batch_pk: Primary key of the batch this file belongs to - sample_id: Identifier for the sample associated with this file - sample_file_id: Identifier for the sample_file associated with this file - total_chunks: Total number of chunks the file will be split into - estimated_completion_time: Estimated time in seconds until upload completes - time_remaining: Time remaining in seconds for the upload - uploadSession: Identifier for the current upload session - file_data: The actual file data to be uploaded - total_chunks: Total number of chunks for this file - """ - - file: dict[str, str] - upload_id: int - batch_pk: int - sample_id: str - sample_file_id: int - total_chunks: int - estimated_completion_time: int - time_remaining: int - uploadSession: int - file_data: Any - total_chunks: int - - -class PreparedFiles(TypedDict): - """A TypedDict representing the prepared files and upload session data. - - Args: - files: List of SelectedFile objects containing file metadata and upload details - uploadSession: Unique identifier for the current upload session - uploadSessionData: Dictionary containing additional metadata about the upload session - """ - - files: list[SelectedFile] - uploadSession: int - uploadSessionData: dict[str, Any] - - -@dataclass -class Metrics: - """A placeholder class for the metrics associated with file uploads.""" - - ... - - -@dataclass -class OnProgress: - """Initializes the OnProgress instance. - - Args: - upload_id (int): The ID the upload. - batch_pk (int): The batch ID associated with the file upload. - progress (float): The percentage of upload completion. - metrics (UploadMetrics): The metrics associated with the upload. - """ - - upload_id: int - batch_pk: int - progress: float - metrics: UploadMetrics - - -@dataclass -class OnComplete: - """Initializes the OnComplete instance. - - Args: - upload_id (int): The ID the upload. - batch_pk (int): The batch ID associated with the file upload. - """ - - upload_id: int - batch_pk: int - - -@dataclass -class UploadData: - """A class representing the parameters related to uploading files.""" - - def __init__( - self, - access_token, - batch_pk, - env, - samples: list[UploadSample], - on_complete: OnComplete | None = None, - on_progress: OnProgress | None = None, - max_concurrent_chunks: int = 5, - max_concurrent_files: int = 3, - upload_session=None, - abort_controller=None, - ): - """Initializes the UploadFileType instance. - - Args: - access_token (str): The access token for authentication. - batch_pk (str): The batch ID for the upload. - env (str): The environment for the upload endpoint. - samples (list[UploadSample]): A list of samples to upload. Defaults to an empty list. - on_complete (Callable[[OnComplete], None]): A callback function to call when the upload is complete. - on_progress (Callable[[OnProgress], None]): A callback function to call during the upload progress. - max_concurrent_chunks (int): The maximum number of chunks to upload concurrently. Defaults to 5. - max_concurrent_files (int): The maximum number of files to upload concurrently. Defaults to 3. - upload_session (int | None): The upload session ID. - abort_controller (Any | None): An optional controller to abort the upload. - """ - self.access_token = access_token - self.batch_pk = batch_pk - self.env = env - self.samples = samples - self.on_complete = on_complete - self.on_progress = on_progress - self.max_concurrent_chunks = max_concurrent_chunks - self.max_concurrent_files = max_concurrent_files - self.upload_session = upload_session - self.abort_controller = abort_controller - - -def get_upload_host(cli_host: str | None = None) -> str: - """Return hostname using 1) CLI argument, 2) environment variable, 3) default value. - - Args: - cli_host (str | None): The host provided via CLI argument. - - Returns: - str: The resolved hostname. - """ - return ( - cli_host - if cli_host is not None - else os.environ.get("PATHOGENA_UPLOAD_HOST", DEFAULT_UPLOAD_HOST) - ) - - -def get_batch_upload_status( - batch_pk: str, -) -> BatchUploadStatus: - """Starts an upload by making a POST request. - - Args: - batch_pk (int): The primary key of the batch. - data (dict[str, Any] | None): Data to include in the POST request body. - - Returns: - dict[str, Any]: The response JSON from the API. - - Raises: - APIError: If the API returns a non-2xx status code. - """ - api = UploadAPIClient() - url = f"{get_protocol()}://{api.base_url}/api/v1/batches/{batch_pk}/state" - try: - response = api.client.get( - url, headers={"Authorization": f"Bearer {api.token}"}, follow_redirects=True - ) - response.raise_for_status() - return response.json() - except httpx.HTTPError as e: - raise APIError( - f"Failed to fetch batch status: {response.text}", - response.status_code, - ) from e - - -def get_file_data_from_resolved_path(reads_resolved_path: Path | None): - """Get the file name, content type, and Path object based on a resolved file path. - - Args: - reads_resolved_path (Path | None): Path to the file to inspect. - - Returns: - tuple[str, str, Path | None]: - name: The file's name, or an empty string if the path is None or doesn't exist. - content_type: "application/gzip" if the file suffix is 'gzip' or 'gz'; otherwise "text/plain". - resolved_path: The Path object if the file exists; otherwise None. - """ - if reads_resolved_path is None or not reads_resolved_path.exists(): - return "", "text/plain", None - - return ( - reads_resolved_path.name, - "application/gzip" - if reads_resolved_path.suffix in ("gzip", "gz") - else "text/plain", - reads_resolved_path, - ) - - -def prepare_files( - batch_pk: str, - samples: list[UploadSample], - api_client: UploadAPIClient, -) -> PreparedFiles: - """Prepares multiple files for upload. - - This function starts the upload session, checks the upload status of the current - sample and if it has not already been uploaded or partially uploaded prepares - the sample from scratch. - - Args: - batch_pk (str): The ID of the batch. - samples (list[UploadSample]): List of samples to prepare the files for. - api_client (UploadAPIClient): Instance of the APIClient class. - - Returns: - PreparedFiles: Prepared file metadata, upload session information, and session data. - """ - selected_files = [] - - # create file metadata depending on if illumina or ont - files: list[SampleFileMetadata] = [] - for sample in samples: - if sample.is_illumina(): - file1_name, file1_content_type, file1_resolved_path = ( - get_file_data_from_resolved_path(sample.reads_1_resolved_path) - ) - file2_name, file2_content_type, file2_resolved_path = ( - get_file_data_from_resolved_path(sample.reads_2_resolved_path) - ) - files.append( - { - "name": file1_name, - "size": sample.file1_size, - "control": sample.control.upper(), - "content_type": file1_content_type, - "specimen_organism": sample.specimen_organism, - "resolved_path": file1_resolved_path, - } - ) - files.append( - { - "name": file2_name, - "size": sample.file2_size, - "control": sample.control.upper(), - "content_type": file2_content_type, - "specimen_organism": sample.specimen_organism, - "resolved_path": file2_resolved_path, - } - ) - else: - file1_name, file1_content_type, file1_resolved_path = ( - get_file_data_from_resolved_path(sample.reads_1_resolved_path) - ) - files.append( - { - "name": file1_name, - "size": sample.file1_size, - "control": sample.control.upper(), - "content_type": file1_content_type, - "specimen_organism": sample.specimen_organism, - "resolved_path": file1_resolved_path, - } - ) - - # create payload for starting upload session from sample metadata - files_to_upload = [] - for file in files: - file_payload = { - "original_file_name": file.get("name"), - "file_size_in_kb": file.get("size"), - } - - if file.get("specimen_organism"): - file_payload["specimen_organism"] = file.get("specimen_organism") - - files_to_upload.append(file_payload) - - form_details = { - "files_to_upload": files_to_upload, - "specimen_organism": files[0].get("specimen_organism"), - } - - try: - session_response = api_client.batches_samples_start_upload_session_create( - batch_pk=batch_pk, data=form_details - ) - except APIError as e: - raise APIError( - f"Error starting session: {str(e)}", - e.status_code, - ) from e - - if not session_response["upload_session"]: - # Log if the upload session could not be resumed - logging.exception( - "Upload session cannot be resumed. Please create a new batch." - ) - raise APIError( - "No upload session returned by the API.", codes.INTERNAL_SERVER_ERROR - ) - - upload_session = session_response["upload_session"] - sample_summaries = session_response["sample_summaries"] - - # assume order is consistent and map out the sample summaries to the files - # ie. illumina samples have two files and ont samples have one - per_file_sample_summaries = ( - [item for item in sample_summaries for _ in range(2)] - if len(sample_summaries) * 2 == len(files_to_upload) - else sample_summaries - ) - - for idx, file_metadata in enumerate(files): - sample_id = per_file_sample_summaries[idx].get("sample_id") - - file_ready = prepare_file( - resolved_path=file_metadata["resolved_path"], - file_metadata=file_metadata, - batch_pk=batch_pk, - upload_session=upload_session, - sample_id=sample_id, - api_client=api_client, - ) - if file_ready: - selected_files.append(file_ready) - - return { - "files": selected_files, - "uploadSession": upload_session, - "uploadSessionData": session_response, - } - - -# upload_all chunks of a file -def upload_chunks( - upload_data: UploadData, - file: SelectedFile, - file_status: dict, - chunk_size: int = DEFAULT_CHUNK_SIZE, -) -> None: - """Uploads chunks of a single file. - - Args: - upload_data (UploadData): The upload data including batch_id, session info, etc. - file (SelectedFile): The file to upload (with file data, total chunks, etc.) - file_status (dict): The dictionary to track the file upload progress. - chunk_size (int): Default size of file chunk to upload (5mb) - - Returns: - None: This function does not return anything, but updates the `file_status` dictionary - and calls the provided `on_progress` and `on_complete` callback functions. - """ - logging.info(f"Uploading {file['file']['name']}") - chunks_uploaded = 0 - chunk_queue = [] - stop_uploading = False - - max_retries = DEFAULT_MAX_UPLOAD_RETRIES - retry_delay = DEFAULT_RETRY_DELAY - for i in range(file["total_chunks"]): # total chunks = file.size/chunk_size - if stop_uploading: - break - - process_queue(chunk_queue, upload_data.max_concurrent_chunks) - - # chunk the files - start = i * chunk_size # 5 MB chunk size default - end = start + chunk_size - file_chunk = file["file_data"][start:end] - - success = False - attempt = 0 - - while attempt < max_retries and not success: - chunk_upload = upload_chunk( - batch_pk=upload_data.batch_pk, - host=get_host(), - protocol=get_protocol(), - chunk=file_chunk, - chunk_index=i, - upload_id=file["upload_id"], - ) - chunk_queue.append(chunk_upload) - try: - chunk_upload_result = chunk_upload.json() - - if chunk_upload.status_code >= 400: - logging.error( - f"Attempt {attempt + 1} of {max_retries}: Chunk upload failed for chunk {i} of batch {upload_data.batch_pk}. Response: {chunk_upload_result.text}" - ) - attempt += 1 - - if attempt < max_retries: - logging.info(f"Retrying upload of chunk {i}") - time.sleep(retry_delay) - continue - else: - stop_uploading = ( - True # stop retrying if have reached max retry attempts - ) - break - - # process result of chunk upload for upload chunks that don't return 400 status - metrics = chunk_upload_result.get("metrics", {}) - if metrics: - chunks_uploaded += 1 - file_status[file["upload_id"]] = { - "chunks_uploaded": chunks_uploaded, - "total_chunks": file["total_chunks"], - "metrics": chunk_upload_result["metrics"], - } - progress = (chunks_uploaded / file["total_chunks"]) * 100 - - # Create an OnProgress instance - progress_event = OnProgress( - upload_id=file["upload_id"], - batch_pk=upload_data.batch_pk, - progress=progress, - metrics=chunk_upload_result["metrics"], - ) - upload_data.on_progress = progress_event - - # If all chunks have been uploaded, complete the file upload - if chunks_uploaded == file["total_chunks"]: - complete_event = OnComplete( - file["upload_id"], upload_data.batch_pk - ) - upload_data.on_complete = complete_event - client = UploadAPIClient() - end_status = client.batches_uploads_end_file_upload( - upload_data.batch_pk, - data={"upload_id": file["upload_id"]}, - ) - if end_status.status_code == 400: - logging.error( - f"Failed to end upload for file: {file['upload_id']} (Batch ID: {upload_data.batch_pk})" - ) - success = True - - except Exception as e: - logging.error( - f"Attempt {attempt + 1} of {max_retries}: Error uploading chunk {i} of batch {upload_data.batch_pk}: {str(e)}" - ) - attempt += 1 - if attempt < max_retries: - logging.info(f"Retrying upload of chunk {i}") - time.sleep(retry_delay) - else: - stop_uploading = True - break - - if not success: - stop_uploading = ( - True # Stop uploading further chunks if some other error occurs - ) - break - - -def upload_files( - upload_data: UploadData, - prepared_files: PreparedFiles, - api_client: UploadAPIClient, -) -> None: - """Uploads files in chunks and manages the upload process. - - This function first prepares the files for upload, then uploads them in chunks - using a thread pool executor for concurrent uploads. It finishes by ending the - upload session. - - Args: - upload_data (UploadData): An object containing the upload configuration, - including the batch ID, access token, environment, and file details. - prepared_files (PreparedFiles): Set of files together with all the metadata needed for upload. - api_client (UploadAPIClient): Instance of the APIClient class. - - Returns: - None - """ - file_status = {} - - # load in prepared files - file_preparation = prepared_files - - # If prepare_files returned None, log and return - if file_preparation is None: - logging.error("Failed to prepare files: no data returned.") - return - - # handle any errors during preparation - error_keys = [k for k in file_preparation if "API error occurred" in k] - if error_keys: - error_msg_key = error_keys[0] - logging.error(f"Error preparing files: {file_preparation[error_msg_key]}") - return - - if "files" not in file_preparation: - logging.error("Unexpected response from prepare_files: 'files' key missing.") - return - - # files have been sucessfully prepared, extract the prepared file list - selected_files = file_preparation["files"] - - # upload the file chunks - with ThreadPoolExecutor(max_workers=upload_data.max_concurrent_chunks) as executor: - futures = [] - for file in selected_files: - future = executor.submit(upload_chunks, upload_data, file, file_status) - futures.append(future) - - for future in as_completed(futures): - try: - future.result() - except Exception as e: - logging.error(f"Error uploading file: {e}") - - # end the upload session - end_session = api_client.batches_samples_end_upload_session_create( - upload_data.batch_pk, upload_data.upload_session - ) - - if end_session.status_code != 200: - logging.error(f"Failed to end upload session for batch {upload_data.batch_pk}.") - else: - logging.info(f"All uploads complete.") - - -def prepare_file( - resolved_path: Path | None, - file_metadata: SampleFileMetadata, - batch_pk: str, - upload_session: int, - sample_id: str, - api_client: UploadAPIClient, - chunk_size: int = DEFAULT_CHUNK_SIZE, -) -> dict[str, Any]: - """Prepares a file for uploading by sending metadata to initialize the process. - - Args: - resolved_path (Path): Resolved path of the file. - file_metadata (Any): A file object with attributes `name`, `size`, `content_type` and `specimen_oragnism`. - batch_pk (str): The batch ID associated with the file. - upload_session (int): The current upload session ID. - sample_id (str): The ID of the sample that the file will be associated with. - chunk_size (int): Size of each file chunk in bytes. - api_client (UploadAPIClient): Instance of the APIClient class. - - Returns: - dict[str, Any]: File metadata ready for upload or error details. - """ - if resolved_path is None: - return { - "error": "Could not find any read file data for sample", - "status code": 500, - "upload_session": upload_session, - } - - with resolved_path.open("rb") as file: - file_data = file.read() - - original_file_name = file_metadata["name"] - total_chunks = math.ceil(sys.getsizeof(file_data) / chunk_size) - content_type = file_metadata["content_type"] - - form_data = { - "original_file_name": original_file_name, - "total_chunks": total_chunks, - "content_type": content_type, - "sample_id": sample_id, - } - - try: - start_file_upload_response = api_client.batches_uploads_start_file_upload( - batch_pk=batch_pk, data=form_data - ) - start_file_upload_json = start_file_upload_response.json() - if start_file_upload_response.status_code == 200: - file_ready = { - "file": file_metadata, - "upload_id": start_file_upload_json.get("upload_id"), - "batch_id": batch_pk, - "sample_id": start_file_upload_json.get("sample_id"), - "sample_file_id": start_file_upload_json.get("sample_file_id"), - "total_chunks": total_chunks, - "upload_session": upload_session, - "file_data": file_data, - } - return file_ready - else: - # Include the upload session in the error response - start_file_upload_json["upload_session"] = upload_session - return start_file_upload_json - except APIError as e: - return { - "error": str(e), - "status code": e.status_code, - "upload_session": upload_session, - } - - -@retry(wait=wait_random_exponential(multiplier=2, max=60), stop=stop_after_attempt(10)) -def upload_chunk( - batch_pk: int, - host: str, - protocol: str, - chunk: bytes, - chunk_index: int, - upload_id: int, -) -> Response: - """Upload a single file chunk. - - Args: - batch_pk (int): ID of sample to upload - host (str): pathogena host, e.g api.upload-dev.eit-pathogena.com - protocol (str): protocol, default https - chunk (bytes): File chunk to be uploaded - chunk_index (int): Index representing what chunk of the whole - sample file this chunk is from 0...total_chunks - upload_id: the id of the upload session - - Returns: - Response: The response object from the HTTP POST request conatining - the status code and content from the server. - """ - try: - with httpx.Client( - event_hooks=httpx_hooks, - transport=httpx.HTTPTransport(retries=5), - timeout=7200, # 2 hours - ) as client: - response = client.post( - f"{protocol}://{get_upload_host()}/api/v1/batches/{batch_pk}/uploads/upload-chunk/", - headers={"Authorization": f"Bearer {get_access_token(host)}"}, - files={"chunk": chunk}, # Send the binary chunk - data={ - "chunk_index": chunk_index, - "upload_id": upload_id, - }, - follow_redirects=True, - ) - - if response.status_code >= 400: - logging.error( - f"Error uploading chunk {chunk_index} of batch {batch_pk}: {response.text}" - ) - return response - else: - return response - except Exception as e: - logging.error( - f"Exception while uploading chunk {chunk_index} of batch {batch_pk}: {str(e), chunk[:10]} RESPONSE {response.status_code, response.headers, response.content}" - ) - raise - - -def process_queue(chunk_queue: list, max_concurrent_chunks: int) -> Generator[Any]: - """Processes a queue of chunks concurrently to ensure tno more than 'max_concurrent_chunks' are processed at the same time. - - Args: - chunk_queue (list): A collection of futures (generated by thread pool executor) - representing the chunks to be processed. - max_concurrent_chunks (int): The maximum number of chunks to be processed concurrently. - """ - if len(chunk_queue) >= max_concurrent_chunks: - completed = [] - for future in as_completed(chunk_queue): - yield future.result() - completed.append(future) - for future in completed: # remove completed futures from queue - chunk_queue.remove(future) - - -def upload_fastq( - upload_data: UploadData, - prepared_files: PreparedFiles, - api_client: UploadAPIClient, -) -> None: - """Upload a FASTQ file to the server. - - Args: - upload_data (UploadData): The upload data including batch_id, session info, etc. - prepared_files (PreparedFiles): Set of files together with all the metadata needed for upload. - api_client (UploadAPIClient): Client for connecting to the Upload API. - """ - upload_files(upload_data, prepared_files, api_client) diff --git a/src/pathogena/util.py b/src/pathogena/util.py index 4c1c381..4eea582 100644 --- a/src/pathogena/util.py +++ b/src/pathogena/util.py @@ -7,8 +7,6 @@ import shutil import subprocess import uuid -from datetime import datetime -from json import JSONDecodeError from pathlib import Path from typing import Literal @@ -18,8 +16,6 @@ load_dotenv() -PLATFORMS = Literal["illumina", "ont"] - def run(cmd: str, cwd: Path = Path()) -> subprocess.CompletedProcess: """Wrapper for running shell command subprocesses. @@ -36,26 +32,6 @@ def run(cmd: str, cwd: Path = Path()) -> subprocess.CompletedProcess: ) -def get_access_token(host: str) -> str: - """Reads token from ~/.config/pathogena/tokens/. - - Args: - host (str): The host for which to retrieve the token. - - Returns: - str: The access token. - """ - token_path = get_token_path(host) - logging.debug(f"{token_path=}") - try: - data = json.loads(token_path.read_text()) - except FileNotFoundError as fne: - raise FileNotFoundError( - f"Token not found at {token_path}, have you authenticated?" - ) from fne - return data["access_token"].strip() - - def parse_csv(csv_path: Path) -> list[dict]: """Parse a CSV file into a list of dictionaries. @@ -192,7 +168,7 @@ def gzip_file(input_file: Path, output_file: str) -> Path: return Path(output_file) -def reads_lines_from_gzip(file_path: Path) -> int: +def count_lines_in_gzip(file_path: Path) -> int: """Count the number of lines in a gzipped file. Args: @@ -257,58 +233,4 @@ def find_duplicate_entries(inputs: list[str]) -> list[str]: list[str]: A list of duplicate items. """ seen = set() - return [f for f in inputs if f in seen or seen.add(f)] - - -def get_token_path(host: str) -> Path: - """Get the path to the token file for a given host. - - Args: - host (str): The host for which to get the token path. - - Returns: - Path: The path to the token file. - """ - conf_dir = Path.home() / ".config" / "pathogena" - token_dir = conf_dir / "tokens" - token_dir.mkdir(parents=True, exist_ok=True) - token_path = token_dir / f"{host}.json" - return token_path - - -def get_token_expiry(host: str) -> datetime | None: - """Get the expiry date of the token for a given host. - - Args: - host (str): The host for which to get the token expiry date. - - Returns: - datetime | None: The expiry date of the token, or None if the token does not exist. - """ - token_path = get_token_path(host) - if token_path.exists(): - try: - with open(token_path) as token: - token = json.load(token) - expiry = token.get("expiry", False) - if expiry: - return datetime.fromisoformat(expiry) - except JSONDecodeError: - return None - return None - - -def is_auth_token_live(host: str) -> bool: - """Check if the authentication token for a given host is still valid. - - Args: - host (str): The host for which to check the token validity. - - Returns: - bool: True if the token is still valid, False otherwise. - """ - expiry = get_token_expiry(host) - if expiry: - logging.debug(f"Token expires: {expiry}") - return expiry > datetime.now() - return False + return [f for f in inputs if f in seen or seen.add(f)] # type: ignore diff --git a/tests/conftest.py b/tests/conftest.py index 9f5905c..2c4e502 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,17 @@ import pytest -from pathogena.create_upload_csv import UploadData -from pathogena.models import UploadBatch, UploadSample, create_batch_from_csv +from pathogena.models import ( + UploadBatch, + UploadData, + UploadSample, + create_batch_from_csv, +) @pytest.fixture(scope="session", autouse=True) def mock_amplicon_scheme(): - with patch("pathogena.lib.get_amplicon_schemes") as get_amplicon_schemes_mock: + with patch("pathogena.tasks.fetch_amplicon_schemes") as get_amplicon_schemes_mock: get_amplicon_schemes_mock.return_value = [] yield @@ -224,7 +228,7 @@ def illumina_multiple_sample_batch(illumina_multiple_sample_csv: Path) -> Upload @pytest.fixture -def invalid_fastq_paths_batch(invalid_fastq_paths_csv: Path) -> Path: +def invalid_fastq_paths_batch(invalid_fastq_paths_csv: Path) -> UploadBatch: return create_batch_from_csv(invalid_fastq_paths_csv) diff --git a/tests/test_build_csv.py b/tests/test_build_csv.py index 5dec94b..1968bf1 100644 --- a/tests/test_build_csv.py +++ b/tests/test_build_csv.py @@ -6,7 +6,8 @@ import pytest from pydantic import ValidationError -from pathogena.create_upload_csv import UploadData, build_upload_csv +from pathogena.models import UploadData +from pathogena.tasks import build_upload_csv def test_build_csv_illumina( @@ -185,7 +186,7 @@ def test_build_csv_invalid_tech(tmp_path: Path, upload_data: UploadData) -> None tmp_path (Path): Temporary path for output files. upload_data (UploadData): Data required for building the upload CSV. """ - upload_data.instrument_platform = "invalid" + upload_data.instrument_platform = "invalid" # type: ignore with pytest.raises(ValueError) as e_info: build_upload_csv( "tests/data/unmatched_files", @@ -209,7 +210,7 @@ def test_build_csv_invalid_specimen_organism( tmp_path (Path): Temporary path for output files. upload_data (UploadData): Data required for building the upload CSV. """ - upload_data.specimen_organism = "invalid" + upload_data.specimen_organism = "invalid" # type: ignore with pytest.raises(ValueError) as e_info: build_upload_csv( "tests/data/empty_files", diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d364f7..f13f7e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,14 +30,6 @@ def test_cli_version(cli_main) -> None: assert version in result.output -# Github Action currently exits 143 with this test, likely what the previous comment meant by "Slow" -# def test_cli_decontaminate_ont(ont_sample_csv): -# runner = CliRunner() -# result = runner.invoke(cli_main, ["decontaminate", str(ont_sample_csv)]) -# assert result.exit_code == 0 -# [os.remove(f) for f in os.listdir(".") if f.endswith("clean.fastq.gz")] - - @pytest.mark.slow def test_cli_decontaminate_illumina(cli_main, illumina_sample_csv: Path) -> None: """Test the CLI decontaminate command for Illumina samples. @@ -48,7 +40,9 @@ def test_cli_decontaminate_illumina(cli_main, illumina_sample_csv: Path) -> None runner = CliRunner() result = runner.invoke(cli_main, ["decontaminate", str(illumina_sample_csv)]) assert result.exit_code == 0 - [os.remove(f) for f in os.listdir(".") if f.endswith(".fastq.gz")] + for f in os.listdir("."): + if f.endswith(".fastq.gz"): + os.remove(f) @pytest.mark.slow @@ -65,7 +59,9 @@ def test_cli_decontaminate_illumina_with_output_dir( cli_main, ["decontaminate", str(illumina_sample_csv), "--output-dir", "."] ) assert result.exit_code == 0 - [os.remove(f) for f in os.listdir(".") if f.endswith(".fastq.gz")] + for f in os.listdir("."): + if f.endswith(".fastq.gz"): + os.remove(f) @pytest.mark.slow @@ -128,6 +124,7 @@ def test_validation_fail_control(cli_main, invalid_control_csv: Path) -> None: runner = CliRunner() result = runner.invoke(cli_main, ["validate", str(invalid_control_csv)]) assert result.exit_code == 1 + assert result.exc_info is not None assert result.exc_info[0] == ValidationError assert "Input should be 'positive', 'negative' or ''" in str(result.exc_info) diff --git a/tests/test_lib.py b/tests/test_tasks.py similarity index 86% rename from tests/test_lib.py rename to tests/test_tasks.py index 8fd55cf..d3ba578 100644 --- a/tests/test_lib.py +++ b/tests/test_tasks.py @@ -4,7 +4,7 @@ import httpx import pytest -from pathogena import lib +from pathogena import tasks from pathogena.errors import UnsupportedClientError @@ -23,7 +23,7 @@ def test_check_new_version_available( mock_get.return_value = httpx.Response( status_code=200, json={"info": {"version": "1.1.0"}} ) - lib.check_for_newer_version() + tasks.check_for_newer_version() assert "A new version of the EIT Pathogena CLI" in caplog.text @@ -42,7 +42,7 @@ def test_check_no_new_version_available( mock_get.return_value = httpx.Response( status_code=200, json={"info": {"version": "1.0.0"}} ) - lib.check_for_newer_version() + tasks.check_for_newer_version() assert not caplog.text @@ -58,7 +58,7 @@ def test_check_version_compatibility( caplog (pytest.LogCaptureFixture): Pytest fixture to capture log output. """ mock_get.return_value = httpx.Response(status_code=200, json={"version": "1.0.0"}) - lib.check_version_compatibility(host=test_host) + tasks.check_version_compatibility(host=test_host) @patch("httpx.Client.get") @@ -75,44 +75,44 @@ def test_fail_check_version_compatibility( caplog.set_level(logging.INFO) mock_get.return_value = httpx.Response(status_code=200, json={"version": "1.0.1"}) with pytest.raises(UnsupportedClientError): - lib.check_version_compatibility(host=test_host) + tasks.check_version_compatibility(host=test_host) assert "is no longer supported" in caplog.text @patch("httpx.Client.get") -@patch("pathogena.lib.get_access_token") +@patch("pathogena.client.env.get_access_token") def test_get_balance( mock_token: MagicMock, mock_get: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test successfully getting the balance for a given account. Args: - mock_token (MagicMock): Mocked `pathogena.lib.get_access_token` method. + mock_token (MagicMock): Mocked `pathogena.client.env.get_access_token` method. mock_get (MagicMock): Mocked `httpx.Client.get` method. caplog (pytest.LogCaptureFixture): Pytest fixture to capture log output. """ caplog.set_level(logging.INFO) mock_token.return_value = "fake_token" mock_get.return_value = httpx.Response(status_code=200, text="1000") - lib.get_credit_balance(host="fake_host") + tasks.fetch_credit_balance(host="fake_host") assert "Your remaining account balance is 1000 credits" in caplog.text @patch("httpx.Client.get") -@patch("pathogena.lib.get_access_token") +@patch("pathogena.client.env.get_access_token") def test_get_balance_failure( mock_token: MagicMock, mock_client_get: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test failure to get the account balance. Args: - mock_token (MagicMock): Mocked `pathogena.lib.get_access_token` method. + mock_token (MagicMock): Mocked `pathogena.client.env.get_access_token` method. mock_client_get (MagicMock): Mocked `httpx.Client.get` method. caplog (pytest.LogCaptureFixture): Pytest fixture to capture log output. """ mock_token.return_value = "fake_token" mock_client_get.return_value = httpx.Response(status_code=402) - lib.get_credit_balance(host="fake_host") + tasks.fetch_credit_balance(host="fake_host") assert ( "Your account doesn't have enough credits to fulfil the number of Samples in your Batch." in caplog.text diff --git a/tests/test_upload.py b/tests/test_upload.py index ba3178a..e94ecad 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -1,152 +1,284 @@ -from collections.abc import Callable, Generator from concurrent.futures import Future -from datetime import date, datetime +from datetime import date from pathlib import Path from typing import Any +from unittest.mock import MagicMock, patch import httpx import pytest -from pytest_mock import MockerFixture -from pathogena.batch_upload_apis import APIError, UploadAPIClient -from pathogena.upload_utils import ( +from pathogena.client.upload_client import APIError, UploadAPIClient +from pathogena.models import UploadSample +from pathogena.tasks.upload import ( + start_upload_session, + upload_chunks, + upload_fastq_files, +) +from pathogena.types import ( OnComplete, OnProgress, - PreparedFiles, + PreparedFile, + Sample, SampleFileMetadata, - SampleFileUploadStatus, - SelectedFile, UploadData, - UploadMetrics, - UploadSample, - prepare_file, - prepare_files, - upload_chunks, - upload_files, + UploadingFile, + UploadSession, ) +TEST_UPLOAD_SESSION_ID = 123 + + +class TestUploadBase: + @pytest.fixture(autouse=True) + def setup(self): + # Set values for the batch and instrument + self.batch_id = "00000000-0000-0000-0000-000000000000" + self.sample_id = "11111111-1111-1111-1111-111111111111" + self.instrument_code = "INST001" + self.upload_session_id = TEST_UPLOAD_SESSION_ID + self.file_data = b"\x1f\x8b\x08\x08\x22\x4e\x01" + + +@pytest.fixture(autouse=True) +def mock_token(): + get_token_patch = patch("pathogena.client.env.get_access_token") + get_token_mock = get_token_patch.start() + get_token_mock.return_value = "test_token" + + yield get_token_mock + + get_token_patch.stop() + @pytest.fixture -def mock_api_client(mocker: Callable[..., Generator[MockerFixture, None, None]]): - return mocker.MagicMock(spec=UploadAPIClient) +def upload_sample_1() -> UploadSample: + return UploadSample( + sample_name="sample1", + upload_csv=Path("tests/data/illumina.csv"), + reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), + reads_2=Path("reads/tuberculosis_1_2.fastq.gz"), + control="positive", + instrument_platform="illumina", + collection_date=date(2024, 12, 10), + country="GBR", + is_illumina=True, + is_ont=False, + ) -class TestPrepareFile: - @pytest.fixture(autouse=True) - def setup(self, mocker: Callable[..., Generator[MockerFixture, None, None]]): - self.file = SampleFileMetadata( - name="file1.txt", - size=1024, # 1 KB - content_type="text/plain", - specimen_organism="mycobacteria", - ) +@pytest.fixture +def upload_sample_2() -> UploadSample: + return UploadSample( + sample_name="sample2", + upload_csv=Path("tests/data/ont.csv"), + reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), + reads_2=None, + control="positive", + instrument_platform="ont", + collection_date=date(2024, 12, 10), + country="GBR", + is_illumina=False, + is_ont=True, + ) + + +# fixture for mock_upload_data +@pytest.fixture(autouse=True) +def upload_data(upload_sample_1, upload_sample_2): + """Fixture for mocked upload data.""" + # mocking UploadFileType with required attributes + samples = [upload_sample_1, upload_sample_2] + return UploadData( + access_token="access_token", + batch_pk=123, + env="env", + samples=samples, + max_concurrent_chunks=2, + max_concurrent_files=2, + upload_session_id=456, + abort_controller=None, + ) - # define upload_data - self.upload_data = UploadSample( - sample_name="sample1", - upload_csv=Path("tests/data/illumina.csv"), - reads_1=Path("reads/tuberculosis_1_1.fastq"), - control="positive", - instrument_platform="illumina", - collection_date=date(2024, 12, 10), - country="GBR", - is_illumina=True, - is_ont=False, - ) - self.sample_id = "99999999-9999-9999-9999-999999999999" - # set values to call prepare files - self.batch_pk = "11111111-1111-1111-1111-111111111111" - self.upload_session = 1234 - self.file_data = b"\x1f\x8b\x08\x08\x22\x4e\x01" +@pytest.fixture +def sample_summarries() -> list[dict]: + return [ + {"sample_id": "11111111-1111-1111-1111-111111111111"}, + {"sample_id": "11111111-1111-1111-1111-111111111111"}, + {"sample_id": "22222222-2222-2222-2222-222222222222"}, + {"sample_id": "22222222-2222-2222-2222-222222222222"}, + ] + + +@pytest.fixture() +def prepared_file(upload_sample_1): + return PreparedFile(upload_sample=upload_sample_1, file_side=1) + + +@pytest.fixture() +def uploading_file(prepared_file): + return UploadingFile( + file_id=1, + upload_id="1", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=4, + prepared_file=prepared_file, + ) + + +@pytest.fixture +def sample_file_metadata() -> SampleFileMetadata: + return SampleFileMetadata( + name="file1.txt", + size=1024, # 1 KB + content_type="text/plain", + specimen_organism="mycobacteria", + resolved_path=None, + control="test_control", + ) + + +@pytest.fixture +def mock_httpx_client(): + return MagicMock(spec=httpx.Client) + + +@pytest.fixture +def upload_api_client(mock_httpx_client, mock_token): + return UploadAPIClient("test_url", mock_httpx_client, TEST_UPLOAD_SESSION_ID) + + +@pytest.fixture +def upload_session(upload_sample_1, upload_sample_2) -> UploadSession: + """Fixture for creating an upload session.""" + return UploadSession( + session_id=123, + name="session", + samples=[ + Sample( + instrument_platform="illumina", + files=[ + UploadingFile( + file_id=1, + upload_id="1", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=2, + prepared_file=PreparedFile(upload_sample_1, 1), + ), + UploadingFile( + file_id=2, + upload_id="2", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=2, + prepared_file=PreparedFile(upload_sample_1, 2), + ), + ], + ), + Sample( + instrument_platform="ont", + files=[ + UploadingFile( + file_id=3, + upload_id="3", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=2, + prepared_file=PreparedFile(upload_sample_2, 1), + ) + ], + ), + ], + ) + + +class TestPrepareFile(TestUploadBase): + def test_prepare_file_success( + self, + upload_api_client: UploadAPIClient, + upload_sample_1: UploadSample, + mock_httpx_client: MagicMock, + ): + prepared_file = PreparedFile(upload_sample=upload_sample_1, file_side=1) - def test_prepare_file_success(self, mock_api_client: Any): # mock successful api response - mock_api_client.batches_uploads_start_file_upload.return_value = httpx.Response( + mock_httpx_client.post.return_value = httpx.Response( status_code=httpx.codes.OK, json={ - "upload_id": "abc123", - "sample_id": "99999999-9999-9999-9999-999999999999", - "sample_file_id": 456, + "upload_id": "test_upload_id", + "sample_id": "test_sample_id", + "sample_file_id": 1, }, ) - # call - result = prepare_file( - resolved_path=self.upload_data.reads_1_resolved_path, - file_metadata=self.file, - batch_pk=self.batch_pk, - upload_session=self.upload_session, - api_client=mock_api_client, + uploading_file = upload_api_client.start_file_upload( + file=prepared_file, + batch_id=self.batch_id, sample_id=self.sample_id, + upload_session_id=self.upload_session_id, chunk_size=5000000, ) - with open("tests/data/reads/tuberculosis_1_1.fastq", "rb") as file: - file_data_to_compare = file.read() - - assert result == { - "file": self.file, - "upload_id": "abc123", - "batch_id": "11111111-1111-1111-1111-111111111111", - "sample_id": "99999999-9999-9999-9999-999999999999", - "sample_file_id": 456, - "total_chunks": 1, # 1024/5000000 = 0.0002, rounds to 1 chunk - "upload_session": 1234, - "file_data": file_data_to_compare, - } - - def test_prepare_file_unsuccessful(self, mock_api_client: Any): - # mock api response with 400 code - mock_api_client.batches_uploads_start_file_upload.return_value = httpx.Response( - status_code=httpx.codes.BAD_REQUEST, json={"error": "Bad Request"} + assert uploading_file == UploadingFile( + file_id=1, + upload_id="test_upload_id", + sample_id="test_sample_id", + batch_id=self.batch_id, + upload_session_id=self.upload_session_id, + total_chunks=1, + prepared_file=prepared_file, ) - # call - result = prepare_file( - resolved_path=self.upload_data.reads_1_resolved_path, - file_metadata=self.file, - batch_pk=self.batch_pk, - upload_session=self.upload_session, - sample_id=self.sample_id, - api_client=mock_api_client, - chunk_size=5000000, + def test_prepare_file_unsuccessful( + self, + upload_api_client: UploadAPIClient, + prepared_file: PreparedFile, + mock_httpx_client: MagicMock, + ): + # mock api response with 400 code + mock_httpx_client.post.return_value = httpx.Response( + status_code=httpx.codes.BAD_REQUEST, json={"error": "Bad Request"} ) - assert result == { - "error": "Bad Request", - "upload_session": 1234, ## assert upload session added to response - } + with pytest.raises(APIError): + upload_api_client.start_file_upload( + file=prepared_file, + batch_id=self.batch_id, + sample_id=self.sample_id, + upload_session_id=self.upload_session_id, + chunk_size=5000000, + ) - def test_prepare_file_apierror(self, mock_api_client: Any): - # mock api response - mock_api_client.batches_uploads_start_file_upload.side_effect = APIError( - "API request failed", 500 - ) - - # call - result = prepare_file( - resolved_path=self.upload_data.reads_1_resolved_path, - file_metadata=self.file, - batch_pk=self.batch_pk, - upload_session=self.upload_session, - sample_id=self.sample_id, - api_client=mock_api_client, - chunk_size=5000000, - ) + def test_prepare_file_api_error( + self, + upload_api_client: UploadAPIClient, + prepared_file: PreparedFile, + mock_httpx_client: MagicMock, + ): + # mock api error with 500 code + mock_httpx_client.post.side_effect = APIError("API request failed", 500) - assert result == { - "error": "API request failed", - "status code": 500, - "upload_session": 1234, - } + with pytest.raises(APIError): + upload_api_client.start_file_upload( + file=prepared_file, + batch_id=self.batch_id, + sample_id=self.sample_id, + upload_session_id=self.upload_session_id, + chunk_size=5000000, + ) class TestPrepareFiles: @pytest.fixture(autouse=True) def setup(self): # Set up multiple files as dictionaries - self.file1 = UploadSample( + self.upload_sample1 = UploadSample( sample_name="sample1", upload_csv=Path("tests/data/illumina.csv"), reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), @@ -159,7 +291,7 @@ def setup(self): is_ont=False, ) - self.file2 = UploadSample( + self.upload_sample2 = UploadSample( sample_name="sample2", upload_csv=Path("tests/data/ont.csv"), reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), @@ -175,516 +307,264 @@ def setup(self): # Set values for the batch and instrument self.batch_pk = 1 self.instrument_code = "INST001" - self.upload_session = 123 + self.upload_session_id = 123 self.sample_summaries = [ {"sample_id": "11111111-1111-1111-1111-111111111111"}, - {"sample_id": "11111111-1111-1111-1111-111111111111"}, - {"sample_id": "22222222-2222-2222-2222-222222222222"}, - {"sample_id": "22222222-2222-2222-2222-222222222222"}, ] - @pytest.fixture - def mock_api_client( - self, mocker: Callable[..., Generator[MockerFixture, None, None]] - ): - """Fixture for mocking the APIClient.""" - return mocker.MagicMock(spec=UploadAPIClient) - def test_prepare_files_success( self, - mock_api_client: Any, - mocker: Callable[..., Generator[MockerFixture, None, None]], ): + # list of files to pass to prepare_files + upload_samples = [self.upload_sample1] + + mock_api_client = MagicMock(spec=UploadAPIClient) + # mock a successful start upload session response - mock_api_client.batches_samples_start_upload_session_create.return_value = { - "upload_session": self.upload_session, - "sample_summaries": self.sample_summaries, - } - # mock prepare_file with successful preparation of new files - mocker.patch( - "pathogena.upload_utils.prepare_file", - side_effect=[ - { - "file": { - "name": "file1.txt", - "size": 10000000, - "type": "text/plain", - }, - "upload_id": "abc123", - "batch_id": self.batch_pk, - "sample_id": 1, - "total_chunks": 2, - "upload_session": self.upload_session, - "file_data": "file1_data", - }, - { - "file": { - "name": "file2.txt", - "size": 20000000, - "type": "text/plain", - }, - "upload_id": "def456", - "batch_id": self.batch_pk, - "sample_id": 2, - "total_chunks": 4, - "upload_session": self.upload_session, - "file_data": "file2_data", - }, - { - "file": { - "name": "file1.txt", - "size": 10000000, - "type": "text/plain", - }, - "upload_id": "qwe456", - "batch_id": self.batch_pk, - "sample_id": 3, - "total_chunks": 2, - "upload_session": self.upload_session, - "file_data": "file2_data", - }, - ], - ) + mock_api_client.start_upload_session.return_value = [ + self.upload_session_id, + "test_name", + self.sample_summaries, + ] - # list of files to pass to prepare_files - files = [self.file1, self.file2] - result = prepare_files( + mock_api_client.start_file_upload.side_effect = [ + UploadingFile( + file_id=1, + upload_id="test_upload_id_1", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=10, + prepared_file=PreparedFile(self.upload_sample1, 1), + ), + UploadingFile( + file_id=2, + upload_id="test_upload_id_2", + sample_id="test_sample_id", + batch_id="test_batch_id", + upload_session_id=0, + total_chunks=10, + prepared_file=PreparedFile(self.upload_sample1, 2), + ), + ] + + upload_session = start_upload_session( self.batch_pk, - files, + upload_samples, mock_api_client, ) - assert len(result["files"]) == 3 - assert result["files"][0]["upload_id"] == "abc123" # file2 (in progress) + assert len(upload_session.samples) == 1 + assert len(upload_session.samples[0].files) == 2 assert ( - result["uploadSession"] == self.upload_session - ) # upload session is resumed - - def test_prepare_files_apierror(self, mock_api_client: Any): - # mock api error - mock_api_client.batches_samples_start_upload_session_create.side_effect = ( - APIError("API request failed when starting upload session", 500) - ) - - # list of files to pass to prepare_files - files = [self.file1, self.file2] - - # call - with pytest.raises(APIError) as excinfo: - prepare_files(self.batch_pk, files, mock_api_client) - - # check error message and status code - assert "API request failed when starting upload session" in str(excinfo.value) - assert excinfo.value.status_code == 500 + upload_session.samples[0].files[0].upload_id == "test_upload_id_1" + ) # file2 (in progress) + assert ( + upload_session.samples[0].files[1].upload_id == "test_upload_id_2" + ) # file2 (in progress) class TestUploadChunks: @pytest.fixture(autouse=True) - def setup(self, mocker: Callable[..., Generator[MockerFixture, None, None]]): + def setup(self): # Set values for the batch, instrument, and upload session self.batch_pk = 123 self.instrument_code = "INST001" self.upload_session = 123 # mock as_completed to simulate completed futures - self.mock_future = mocker.MagicMock(spec=Future) - self.mock_future.result.return_value = mocker.MagicMock( + self.mock_future = MagicMock(spec=Future) + self.mock_future.result.return_value = MagicMock( status_code=200, text="OK", data={"metrics": "some_metrics"} ) - mocker.patch( + patch( "concurrent.futures.as_completed", return_value=[self.mock_future] * 4 ) # 4 completed chunks to match mock file # Mock process_queue to prevent it from blocking the test - mocker.patch("pathogena.upload_utils.process_queue", return_value=None) + patch("pathogena.upload_utils.process_queue", return_value=None) # Mock access_token dummy_token = "dummy-token" - mocker.patch( - "pathogena.upload_utils.get_access_token", return_value=dummy_token - ) + patch("pathogena.upload_utils.get_access_token", return_value=dummy_token) # mock as_completed to simulate completed futures - self.mock_end_upload = mocker.patch.object( + self.mock_end_upload = patch.object( UploadAPIClient, - "batches_uploads_end_file_upload", + "end_file_upload", return_value=httpx.Response( status_code=httpx.codes.OK, ), - ) - - # fixture for mock_upload_data - @pytest.fixture(autouse=True) - def mock_upload_data( - self, mocker: Callable[..., Generator[MockerFixture, None, None]] - ): - """Fixture for mocked upload data.""" - # mocking UploadFileType with required attributes - samples = [ - UploadSample( - sample_name="sample1", - upload_csv=Path("tests/data/illumina.csv"), - reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), - reads_2=Path("reads/tuberculosis_1_2.fastq.gz"), - control="positive", - instrument_platform="illumina", - collection_date=date(2024, 12, 10), - country="GBR", - is_illumina=True, - is_ont=False, - ), - ] - return UploadData( - access_token="access_token", - batch_pk=123, - env="env", - samples=samples, - max_concurrent_chunks=2, - max_concurrent_files=2, - upload_session=456, - abort_controller=None, - ) - - # fixture for mock_file - @pytest.fixture(autouse=True) - def mock_file(self): - mock_file = SelectedFile( - file={"name": "file1"}, - upload_id=123, - batch_pk=456, - sample_id=678, - total_chunks=4, - estimated_completion_time=5, - time_remaining=3, - uploadSession=123, - file_data=[b"chunk1", b"chunk2", b"chunk3", b"chunk4"], - ) - return mock_file - - # fixture for mock_file_status - @pytest.fixture - def mock_file_status(self): - return {} + ).start() def test_upload_chunks_success( self, - mock_upload_data: UploadData, - mock_file: SelectedFile, - mock_file_status: dict, - mocker: Callable[..., Generator[MockerFixture, None, None]], + upload_data: UploadData, + uploading_file: UploadingFile, ): mock_upload_success = httpx.Response(200, json={"metrics": "some_metrics"}) - - mocker.patch( - "pathogena.upload_utils.upload_chunk", + patch_upload_chunk = patch( + "pathogena.client.upload_client.UploadAPIClient.upload_chunk", return_value=mock_upload_success, - # side_effect=mock_upload_success, ) - mock_client = mocker.patch( - "pathogena.upload_utils.UploadAPIClient", - ) - mock_batches_uploads_end_file_upload = mocker.MagicMock() - mock_batches_uploads_end_file_upload.side_effect = httpx.Response( - 200, json={"metrics": "some_metrics"} - ) - mock_client.batches_uploads_end_file_upload = ( - mock_batches_uploads_end_file_upload + patch_upload_chunk.start() + + patch_client = patch( + "pathogena.client.upload_client.UploadAPIClient", ) + mock_client = patch_client.start() - upload_chunks(mock_upload_data, mock_file, mock_file_status) + mock_end_file_upload = MagicMock() + mock_end_file_upload.side_effect = mock_upload_success + mock_client.end_file_upload = mock_end_file_upload - assert mock_upload_data.on_complete == OnComplete( - mock_file["upload_id"], mock_upload_data.batch_pk + client = UploadAPIClient() + upload_chunks(client, upload_data, uploading_file) + + assert upload_data.on_complete == OnComplete( + uploading_file.upload_id, upload_data.batch_pk ) # all 4 chunks uploaded - assert mock_file_status[mock_file.get("upload_id")]["chunks_uploaded"] == 4 assert ( - mock_file_status[mock_file.get("upload_id")]["chunks_uploaded"] - == mock_file["total_chunks"] + upload_data.on_progress is not None + and upload_data.on_progress.progress == 100 ) - assert ( - self.mock_end_upload.calledonce - ) # batches_uploads_end_file_upload called once + + assert self.mock_end_upload.calledonce # end_file_upload called once + patch_upload_chunk.stop() + patch_client.stop() def test_upload_chunks_retry_on_400( self, - mock_upload_data: UploadData, - mock_file: SelectedFile, - mock_file_status: dict, - mocker: Callable[..., Generator[MockerFixture, None, None]], - caplog: pytest.LogCaptureFixture, + upload_data: UploadData, + uploading_file: UploadingFile, + mock_httpx_client: MagicMock, ): - # mock the first chunk to succeed and the following to fail with a 400 - # need response json too as check it in code - mock_upload_success = mocker.Mock() - mock_upload_success.status_code = 200 - mock_upload_success.text = "OK" - mock_upload_success.json = lambda: {"metrics": "some_metrics"} - - mock_upload_fail = mocker.MagicMock() - mock_upload_fail.status_code = 400 - mock_upload_fail.text = "Bad Request" - mock_upload_fail.json = lambda: {"status_code": 400} - - mock_upload_fail_2 = mocker.MagicMock() - mock_upload_fail_2.status_code = 400 - mock_upload_fail_2.text = "Bad Request Retry" - mock_upload_fail_2.json = lambda: {"status_code": 400} - - mock_upload_fail_3 = mocker.MagicMock() - mock_upload_fail_3.status_code = 400 - mock_upload_fail_3.text = "Bad Request Third" - mock_upload_fail_3.json = lambda: {"status_code": 400} - - # mock upload_chunk to return the above mocks - mock_upload_chunk = mocker.patch( - "pathogena.upload_utils.upload_chunk", - side_effect=[ - mock_upload_success, - mock_upload_fail, - mock_upload_fail_2, - mock_upload_fail_3, - ], + success_response = httpx.Response( + status_code=httpx.codes.OK, + json={"metrics": "some_metrics"}, + ) + fail_response = httpx.Response( + status_code=httpx.codes.BAD_REQUEST, + json={}, ) + mock_httpx_client.post.side_effect = [ + fail_response, + fail_response, + success_response, + success_response, + success_response, + success_response, + ] + client = UploadAPIClient("", mock_httpx_client, 1) + # call - upload_chunks(mock_upload_data, mock_file, mock_file_status) + upload_chunks(client, upload_data, uploading_file) - assert mock_upload_data.on_progress == OnProgress( - upload_id=mock_file["upload_id"], - batch_pk=mock_upload_data.batch_pk, - progress=25, + assert upload_data.on_progress == OnProgress( + upload_id=uploading_file.upload_id, + batch_pk=upload_data.batch_pk, + progress=100, metrics="some_metrics", - ) # only chunk 1 of 4 was uploaded - assert mock_upload_data.on_complete is None # not completed all chunks - assert ( - not self.mock_end_upload.called - ) # batches_uploads_end_file_upload should not be called as 2nd upload failed - assert mock_upload_chunk.call_count == 4 - assert ( - "Retrying upload of chunk 1" in caplog.text - ) # retrying upload captured in logging - assert ( - "Attempt 3 of 3" in caplog.text - ) # retry has been done DEFAULT_MAX_UPLOAD_RETRIES times - - def test_upload_chunks_error_handling( - self, - mock_upload_data: UploadData, - mock_file: SelectedFile, - mock_file_status: dict, - mocker: Callable[..., Generator[MockerFixture, None, None]], - caplog: pytest.LogCaptureFixture, - ): - # mock the first chunk to raise an exception - mock_upload_1 = mocker.MagicMock() - mock_upload_1.json.side_effect = Exception("Some error") - - mock_upload_2 = mocker.MagicMock() - mock_upload_2.json.side_effect = Exception("Some error") - - mock_upload_3 = mocker.MagicMock() - mock_upload_3.json.side_effect = Exception("Some error") - - # mock upload_chunk to return the above mock - mocker.patch( - "pathogena.upload_utils.upload_chunk", - side_effect=[mock_upload_1, mock_upload_2, mock_upload_3], ) - # call - upload_chunks(mock_upload_data, mock_file, mock_file_status) - - assert mock_upload_data.on_progress is None # no progress, errors before - assert mock_upload_data.on_complete is None # not completed all chunks - assert not self.mock_end_upload.called # batches_uploads_end_file_upload should not be called since there was an error - assert ( - "Retrying upload of chunk 0" in caplog.text - ) # retrying upload captured in logging - assert ( - "Error uploading chunk 0 of batch 123:" in caplog.text - ) # error, chunk number and batch pk captured in logging - class TestUploadFiles: - @pytest.fixture - def mock_upload_data(self): - """Fixture for mocked upload data.""" - # mocking UploadFileType with required attributes - samples = [ - UploadSample( - sample_name="sample1", - upload_csv=Path("tests/data/illumina.csv"), - reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), - reads_2=Path("reads/tuberculosis_1_2.fastq.gz"), - control="positive", - instrument_platform="illumina", - collection_date=date(2024, 12, 10), - country="GBR", - is_illumina=True, - is_ont=False, - ), - UploadSample( - sample_name="sample2", - upload_csv=Path("tests/data/ont.csv"), - reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), - control="positive", - instrument_platform="ont", - collection_date=date(2024, 12, 10), - country="GBR", - is_illumina=False, - is_ont=True, - ), - ] - - return UploadData( - access_token="access_token", - batch_pk=123, - env="env", - samples=samples, - on_complete=None, - on_progress=None, - max_concurrent_chunks=2, - max_concurrent_files=2, - upload_session=456, - abort_controller=None, + @pytest.fixture(autouse=True) + def setup(self): + # Set up multiple files as dictionaries + self.upload_sample1 = UploadSample( + sample_name="sample1", + upload_csv=Path("tests/data/illumina.csv"), + reads_1=Path("reads/tuberculosis_1_1.fastq.gz"), + reads_2=Path("reads/tuberculosis_1_2.fastq.gz"), + control="positive", + instrument_platform="illumina", + collection_date=date(2024, 12, 10), + country="GBR", + is_illumina=True, + is_ont=False, ) @pytest.fixture - def mock_sample_uploads(self): - """Fixture for mocked sample uploads.""" - # return {"file1.txt": "pending", "file2.txt": "pending"} - return None - - @pytest.fixture - def mock_api_client( - self, mocker: Callable[..., Generator[MockerFixture, None, None]] - ): - """Fixture for mocking the APIClient.""" - return mocker.MagicMock(spec=UploadAPIClient) - - @pytest.fixture - def mock_successful_prepare_files(self) -> PreparedFiles: - """Fixture for successful PreparedFiles.""" - return { - "files": [ - SelectedFile( - file={"file1": "name"}, - upload_id=456, - batch_pk=123, - sample_id=678, - total_chunks=5, - estimated_completion_time=5, - time_remaining=3, - uploadSession=123, - file_data="file data", - ), - SelectedFile( - file={"file2": "name"}, - upload_id=789, - batch_pk=456, - sample_id=890, - total_chunks=5, - estimated_completion_time=5, - time_remaining=3, - uploadSession=123, - file_data="file2 data", - ), - ], - "uploadSession": 123, - "uploadSessionData": {"data": "some_data"}, - } - - @pytest.fixture - def mock_unsuccessful_prepare_files(self) -> dict[str, str]: + def mock_fail_to_start_upload_session(self) -> dict[str, str]: """Fixture for unsuccessful PreparedFiles.""" return {"API error occurred": "Test error"} def test_upload_files_success( self, - mock_upload_data: UploadData, - mock_sample_uploads: None, - mock_api_client: Any, - mock_successful_prepare_files: PreparedFiles, - mocker: Callable[..., Generator[MockerFixture, None, None]], + upload_data: UploadData, + upload_api_client: UploadAPIClient, + upload_session: UploadSession, ): - # mock successful prepare files - prepared_files = mock_successful_prepare_files - - # mock successful upload_chunks - mock_upload_chunks = mocker.patch( - "pathogena.upload_utils.upload_chunks", return_value=None + patch_upload_chunks = patch.object( + UploadAPIClient, + "upload_chunk", + return_value=httpx.Response( + status_code=httpx.codes.OK, + ), ) + mock_upload_chunks = patch_upload_chunks.start() - # mock successful API client response - mocker.patch.object( + patch_end_upload_session = patch.object( UploadAPIClient, - "batches_samples_end_upload_session_create", + "end_upload_session", return_value=httpx.Response( status_code=httpx.codes.OK, ), ) + mock_end_upload_session = patch_end_upload_session.start() - # call - upload_files( - mock_upload_data, - prepared_files, - mock_api_client, + upload_fastq_files( + upload_api_client, + upload_data, + upload_session, ) - assert mock_upload_chunks.call_count == 2 # upload chunks called for each file - mock_api_client.batches_samples_end_upload_session_create.assert_called_once() - # end session once + assert mock_upload_chunks.call_count == 6 # upload chunks called for each file + mock_end_upload_session.assert_called_once() - def test_upload_files_prepare_api_error( - self, - mock_upload_data: UploadData, - mock_unsuccessful_prepare_files, - mock_sample_uploads: None, - mock_api_client: Any, - mocker: Callable[..., Generator[MockerFixture, None, None]], - caplog: pytest.LogCaptureFixture, - ): - # call - upload_files( - mock_upload_data, - mock_unsuccessful_prepare_files, - mock_api_client, - ) + patch_upload_chunks.stop() + patch_end_upload_session.stop() - # assert correct error is logged - assert "Error preparing files: Test error" in caplog.text - - def test_upload_files_chunk_upload_error( + def test_upload_files_upload_chunks_error( self, - mock_upload_data: UploadData, - mock_successful_prepare_files: PreparedFiles, - mock_sample_uploads: None, - mock_api_client: Any, - mocker: Callable[..., Generator[MockerFixture, None, None]], - caplog: pytest.LogCaptureFixture, + upload_data: UploadData, + upload_session: UploadSession, + upload_api_client: Any, ): - # mock successful prepare files - prepared_files = mock_successful_prepare_files - - # mock upload_chunks with exception - mock_upload_chunks = mocker.patch( - "pathogena.upload_utils.upload_chunks", - side_effect=Exception("Chunk upload error"), + patch_upload_chunks = patch.object( + UploadAPIClient, + "upload_chunk", + return_value=httpx.Response( + status_code=httpx.codes.BAD_REQUEST, + ), ) + patch_upload_chunks.start() - # mock successful API client response - mocker.patch.object( + patch_end_upload_session = patch.object( UploadAPIClient, - "batches_samples_end_upload_session_create", + "end_upload_session", return_value=httpx.Response( - status_code=httpx.codes.OK, + status_code=httpx.codes.BAD_REQUEST, ), ) + patch_end_upload_session.start() - # call - upload_files(mock_upload_data, prepared_files, mock_api_client) + patch_logging = patch("logging.error") + mock_logging = patch_logging.start() - assert mock_upload_chunks.call_count == 2 # upload chunks called twice - assert ( - "Error uploading file: Chunk upload error" in caplog.text - ) # correct error is logged + upload_fastq_files(upload_api_client, upload_data, upload_session) + + for sample in upload_session.samples: + for file in sample.files: + for chunk_index in range(0, 1): + e = "Expecting value: line 1 column 1 (char 0)" + mock_logging.assert_any_call( + f"Error uploading chunk {chunk_index} for file: {file.upload_id} of batch {upload_data.batch_pk}: {str(e)}" + ) + + mock_logging.assert_any_call("Failed to end upload session for batch 123.") + + patch_upload_chunks.stop() + patch_end_upload_session.stop() diff --git a/tests/test_util.py b/tests/test_util.py index 5ce4866..2850603 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,18 +5,16 @@ def test_reads_lines_from_gzip() -> None: """Test that the `reads_lines_from_gzip` function correctly reads the expected number of lines from a gzip file.""" - expected_lines = 4 file_path = Path(__file__).parent / "data" / "reads" / "tuberculosis_1_1.fastq.gz" - lines = util.reads_lines_from_gzip(file_path=file_path) - assert lines == expected_lines + num_lines = util.count_lines_in_gzip(file_path=file_path) + assert num_lines == 4 def test_reads_lines_from_fastq() -> None: """Test that the `reads_lines_from_fastq` function correctly reads the expected number of lines from a fastq file.""" - expected_lines = 4 file_path = Path(__file__).parent / "data" / "reads" / "tuberculosis_1_1.fastq" - lines = util.reads_lines_from_fastq(file_path=file_path) - assert lines == expected_lines + num_lines = util.reads_lines_from_fastq(file_path=file_path) + assert num_lines == 4 def test_fail_command_exists() -> None: @@ -27,14 +25,12 @@ def test_fail_command_exists() -> None: def test_find_duplicate_entries() -> None: """Test that the `find_duplicate_entries` function correctly identifies duplicate entries in a list.""" data = ["foo", "foo", "bar", "bar", "baz"] - expected = ["foo", "bar"] duplicates = util.find_duplicate_entries(data) - assert duplicates == expected + assert duplicates == ["foo", "bar"] def test_find_no_duplicate_entries() -> None: """Test that the `find_duplicate_entries` function correctly identifies that there are no duplicate entries in a list.""" data = ["foo", "bar"] - expected = [] duplicates = util.find_duplicate_entries(data) - assert duplicates == expected + assert duplicates == [] diff --git a/uv.lock b/uv.lock index 6af5de7..5fc3fe1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 1 +revision = 2 requires-python = ">=3.10" [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -21,88 +21,88 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126, upload_time = "2025-01-05T13:13:11.095Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041, upload_time = "2025-01-05T13:13:07.985Z" }, ] [[package]] name = "certifi" version = "2024.12.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010, upload_time = "2024-12-14T13:52:38.02Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927, upload_time = "2024-12-14T13:52:36.114Z" }, ] [[package]] name = "cfgv" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload_time = "2023-08-12T20:38:17.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload_time = "2023-08-12T20:38:16.269Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, - { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, - { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, - { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, - { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, - { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, - { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, - { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, - { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, - { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, - { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, - { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, - { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, - { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, - { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, - { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, - { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, - { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, - { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, - { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, - { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, - { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, - { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, - { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, - { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, - { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload_time = "2024-12-24T18:12:35.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013, upload_time = "2024-12-24T18:09:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285, upload_time = "2024-12-24T18:09:48.113Z" }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449, upload_time = "2024-12-24T18:09:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892, upload_time = "2024-12-24T18:09:52.078Z" }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123, upload_time = "2024-12-24T18:09:54.575Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943, upload_time = "2024-12-24T18:09:57.324Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063, upload_time = "2024-12-24T18:09:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578, upload_time = "2024-12-24T18:10:02.357Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629, upload_time = "2024-12-24T18:10:03.678Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778, upload_time = "2024-12-24T18:10:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453, upload_time = "2024-12-24T18:10:08.848Z" }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479, upload_time = "2024-12-24T18:10:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790, upload_time = "2024-12-24T18:10:11.323Z" }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995, upload_time = "2024-12-24T18:10:12.838Z" }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471, upload_time = "2024-12-24T18:10:14.101Z" }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831, upload_time = "2024-12-24T18:10:15.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335, upload_time = "2024-12-24T18:10:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862, upload_time = "2024-12-24T18:10:19.743Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673, upload_time = "2024-12-24T18:10:21.139Z" }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211, upload_time = "2024-12-24T18:10:22.382Z" }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039, upload_time = "2024-12-24T18:10:24.802Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939, upload_time = "2024-12-24T18:10:26.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075, upload_time = "2024-12-24T18:10:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340, upload_time = "2024-12-24T18:10:32.679Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205, upload_time = "2024-12-24T18:10:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441, upload_time = "2024-12-24T18:10:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload_time = "2024-12-24T18:10:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload_time = "2024-12-24T18:10:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload_time = "2024-12-24T18:10:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload_time = "2024-12-24T18:10:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload_time = "2024-12-24T18:10:50.589Z" }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload_time = "2024-12-24T18:10:52.541Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload_time = "2024-12-24T18:10:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload_time = "2024-12-24T18:10:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload_time = "2024-12-24T18:10:57.647Z" }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload_time = "2024-12-24T18:10:59.43Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload_time = "2024-12-24T18:11:00.676Z" }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload_time = "2024-12-24T18:11:01.952Z" }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload_time = "2024-12-24T18:11:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload_time = "2024-12-24T18:11:05.834Z" }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload_time = "2024-12-24T18:11:07.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload_time = "2024-12-24T18:11:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload_time = "2024-12-24T18:11:09.831Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload_time = "2024-12-24T18:11:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload_time = "2024-12-24T18:11:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload_time = "2024-12-24T18:11:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload_time = "2024-12-24T18:11:17.672Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload_time = "2024-12-24T18:11:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload_time = "2024-12-24T18:11:21.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload_time = "2024-12-24T18:11:22.774Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload_time = "2024-12-24T18:11:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload_time = "2024-12-24T18:11:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload_time = "2024-12-24T18:12:32.852Z" }, ] [[package]] @@ -112,54 +112,54 @@ 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/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } 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/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "distlib" version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload_time = "2024-10-09T18:35:47.551Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload_time = "2024-10-09T18:35:44.272Z" }, ] [[package]] name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload_time = "2024-04-23T18:57:18.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload_time = "2024-04-23T18:57:14.835Z" }, ] [[package]] name = "exceptiongroup" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload_time = "2024-07-12T22:26:00.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload_time = "2024-07-12T22:25:58.476Z" }, ] [[package]] name = "filelock" version = "3.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload_time = "2024-09-17T19:02:01.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload_time = "2024-09-17T19:02:00.268Z" }, ] [[package]] @@ -173,27 +173,27 @@ dependencies = [ { name = "requests" }, { name = "tomli-w" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/46/f84b8815d161e7392d124d3de6e5880d1d36a74162a77a5e2839dc3c8c68/flit-3.10.1.tar.gz", hash = "sha256:9c6258ae76d218ce60f9e39a43ca42006a3abcc5c44ea6bb2a1daa13857a8f1a", size = 143162 } +sdist = { url = "https://files.pythonhosted.org/packages/03/46/f84b8815d161e7392d124d3de6e5880d1d36a74162a77a5e2839dc3c8c68/flit-3.10.1.tar.gz", hash = "sha256:9c6258ae76d218ce60f9e39a43ca42006a3abcc5c44ea6bb2a1daa13857a8f1a", size = 143162, upload_time = "2024-11-02T16:22:09.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/ba/d88b8f3253b4af5a88559aede6345975cc2b18ed77bf8daf977bbb9df2c5/flit-3.10.1-py3-none-any.whl", hash = "sha256:d79c19c2caae73cc486d3d827af6a11c1a84b9efdfab8d9683b714ec8d1dc1f1", size = 50683 }, + { url = "https://files.pythonhosted.org/packages/b6/ba/d88b8f3253b4af5a88559aede6345975cc2b18ed77bf8daf977bbb9df2c5/flit-3.10.1-py3-none-any.whl", hash = "sha256:d79c19c2caae73cc486d3d827af6a11c1a84b9efdfab8d9683b714ec8d1dc1f1", size = 50683, upload_time = "2024-11-02T16:22:05.702Z" }, ] [[package]] name = "flit-core" version = "3.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/ae/09427bea9227a33ec834ed5461432752fd5d02b14f93dd68406c91684622/flit_core-3.10.1.tar.gz", hash = "sha256:66e5b87874a0d6e39691f0e22f09306736b633548670ad3c09ec9db03c5662f7", size = 42842 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/ae/09427bea9227a33ec834ed5461432752fd5d02b14f93dd68406c91684622/flit_core-3.10.1.tar.gz", hash = "sha256:66e5b87874a0d6e39691f0e22f09306736b633548670ad3c09ec9db03c5662f7", size = 42842, upload_time = "2024-11-02T16:22:11.306Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2d/293fe6a58e73df57cc2b5e5cf2b17c6bb4fb5b0c390bab8f1e87bdc62529/flit_core-3.10.1-py3-none-any.whl", hash = "sha256:cb31a76e8b31ad3351bb89e531f64ef2b05d1e65bd939183250bf81ddf4922a8", size = 36389 }, + { url = "https://files.pythonhosted.org/packages/be/2d/293fe6a58e73df57cc2b5e5cf2b17c6bb4fb5b0c390bab8f1e87bdc62529/flit_core-3.10.1-py3-none-any.whl", hash = "sha256:cb31a76e8b31ad3351bb89e531f64ef2b05d1e65bd939183250bf81ddf4922a8", size = 36389, upload_time = "2024-11-02T16:22:07.572Z" }, ] [[package]] name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload_time = "2022-09-25T15:40:01.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload_time = "2022-09-25T15:39:59.68Z" }, ] [[package]] @@ -204,9 +204,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload_time = "2024-11-15T12:30:47.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload_time = "2024-11-15T12:30:45.782Z" }, ] [[package]] @@ -219,54 +219,54 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "identify" version = "2.6.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213, upload_time = "2025-01-04T17:01:41.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078 }, + { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078, upload_time = "2025-01-04T17:01:40.667Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "iniconfig" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload_time = "2023-01-07T11:08:11.254Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload_time = "2023-01-07T11:08:09.864Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload_time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload_time = "2024-06-04T18:44:08.352Z" }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload_time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload_time = "2024-11-08T09:47:44.722Z" }, ] [[package]] @@ -278,6 +278,7 @@ dependencies = [ { name = "packaging" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "pytest-mock" }, { name = "python-dotenv" }, { name = "tenacity" }, { name = "tqdm" }, @@ -311,6 +312,7 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" }, { name = "pydantic", specifier = ">=2.6.2,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.1" }, @@ -332,27 +334,27 @@ dev = [ name = "pip" version = "24.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/b422acd212ad7eedddaf7981eee6e5de085154ff726459cf2da7c5a184c1/pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99", size = 1931073 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/b422acd212ad7eedddaf7981eee6e5de085154ff726459cf2da7c5a184c1/pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99", size = 1931073, upload_time = "2024-10-27T18:35:56.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/7d/500c9ad20238fcfcb4cb9243eede163594d7020ce87bd9610c9e02771876/pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", size = 1822182 }, + { url = "https://files.pythonhosted.org/packages/ef/7d/500c9ad20238fcfcb4cb9243eede163594d7020ce87bd9610c9e02771876/pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", size = 1822182, upload_time = "2024-10-27T18:35:53.067Z" }, ] [[package]] name = "platformdirs" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/dc/c1d911bf5bb0fdc58cc05010e9f3efe3b67970cef779ba7fbc3183b987a8/platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768", size = 20055 } +sdist = { url = "https://files.pythonhosted.org/packages/96/dc/c1d911bf5bb0fdc58cc05010e9f3efe3b67970cef779ba7fbc3183b987a8/platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768", size = 20055, upload_time = "2024-01-31T01:00:36.02Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/72/4898c44ee9ea6f43396fbc23d9bfaf3d06e01b83698bdf2e4c919deceb7c/platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", size = 17717 }, + { url = "https://files.pythonhosted.org/packages/55/72/4898c44ee9ea6f43396fbc23d9bfaf3d06e01b83698bdf2e4c919deceb7c/platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", size = 17717, upload_time = "2024-01-31T01:00:34.019Z" }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload_time = "2024-04-20T21:34:42.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload_time = "2024-04-20T21:34:40.434Z" }, ] [[package]] @@ -366,9 +368,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678, upload_time = "2024-10-08T16:09:37.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713, upload_time = "2024-10-08T16:09:35.726Z" }, ] [[package]] @@ -380,9 +382,9 @@ dependencies = [ { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287, upload_time = "2025-01-09T13:33:25.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426 }, + { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426, upload_time = "2025-01-09T13:33:22.312Z" }, ] [[package]] @@ -392,72 +394,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload_time = "2024-12-18T11:31:54.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938, upload_time = "2024-12-18T11:27:14.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684, upload_time = "2024-12-18T11:27:16.489Z" }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169, upload_time = "2024-12-18T11:27:22.16Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227, upload_time = "2024-12-18T11:27:25.097Z" }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695, upload_time = "2024-12-18T11:27:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662, upload_time = "2024-12-18T11:27:30.798Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370, upload_time = "2024-12-18T11:27:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813, upload_time = "2024-12-18T11:27:37.111Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287, upload_time = "2024-12-18T11:27:40.566Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414, upload_time = "2024-12-18T11:27:43.757Z" }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301, upload_time = "2024-12-18T11:27:47.36Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685, upload_time = "2024-12-18T11:27:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876, upload_time = "2024-12-18T11:27:53.54Z" }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload_time = "2024-12-18T11:27:55.409Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload_time = "2024-12-18T11:27:57.252Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload_time = "2024-12-18T11:27:59.146Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload_time = "2024-12-18T11:28:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload_time = "2024-12-18T11:28:04.442Z" }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload_time = "2024-12-18T11:28:07.679Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload_time = "2024-12-18T11:28:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload_time = "2024-12-18T11:28:13.362Z" }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload_time = "2024-12-18T11:28:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload_time = "2024-12-18T11:28:18.407Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload_time = "2024-12-18T11:28:21.471Z" }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload_time = "2024-12-18T11:28:23.53Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload_time = "2024-12-18T11:28:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload_time = "2024-12-18T11:28:28.593Z" }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload_time = "2024-12-18T11:28:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload_time = "2024-12-18T11:28:32.521Z" }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload_time = "2024-12-18T11:28:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload_time = "2024-12-18T11:28:36.488Z" }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload_time = "2024-12-18T11:28:39.409Z" }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload_time = "2024-12-18T11:28:41.221Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload_time = "2024-12-18T11:28:44.709Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload_time = "2024-12-18T11:28:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload_time = "2024-12-18T11:28:48.896Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload_time = "2024-12-18T11:28:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload_time = "2024-12-18T11:28:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload_time = "2024-12-18T11:28:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload_time = "2024-12-18T11:28:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload_time = "2024-12-18T11:29:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload_time = "2024-12-18T11:29:03.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload_time = "2024-12-18T11:29:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload_time = "2024-12-18T11:29:07.294Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload_time = "2024-12-18T11:29:09.249Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload_time = "2024-12-18T11:29:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload_time = "2024-12-18T11:29:16.396Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload_time = "2024-12-18T11:29:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload_time = "2024-12-18T11:29:23.877Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload_time = "2024-12-18T11:29:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload_time = "2024-12-18T11:29:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload_time = "2024-12-18T11:29:31.338Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload_time = "2024-12-18T11:29:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload_time = "2024-12-18T11:29:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload_time = "2024-12-18T11:29:37.649Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159, upload_time = "2024-12-18T11:30:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331, upload_time = "2024-12-18T11:30:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467, upload_time = "2024-12-18T11:31:00.6Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797, upload_time = "2024-12-18T11:31:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839, upload_time = "2024-12-18T11:31:09.775Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861, upload_time = "2024-12-18T11:31:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582, upload_time = "2024-12-18T11:31:17.423Z" }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985, upload_time = "2024-12-18T11:31:19.901Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload_time = "2024-12-18T11:31:22.821Z" }, ] [[package]] @@ -472,9 +474,9 @@ dependencies = [ { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload_time = "2024-12-01T12:54:25.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload_time = "2024-12-01T12:54:19.735Z" }, ] [[package]] @@ -484,62 +486,62 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814, upload_time = "2024-03-21T22:14:04.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863, upload_time = "2024-03-21T22:14:02.694Z" }, ] [[package]] name = "python-dotenv" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload_time = "2024-01-23T06:33:00.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload_time = "2024-01-23T06:32:58.246Z" }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, ] [[package]] @@ -552,100 +554,100 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload_time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload_time = "2024-05-29T15:37:47.027Z" }, ] [[package]] name = "ruff" version = "0.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844 } +sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844, upload_time = "2025-01-10T18:57:53.896Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241 }, - { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308 }, - { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960 }, - { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803 }, - { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929 }, - { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717 }, - { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921 }, - { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074 }, - { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093 }, - { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610 }, - { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273 }, - { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314 }, - { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982 }, - { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750 }, - { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331 }, - { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708 }, + { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241, upload_time = "2025-01-10T18:56:45.897Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066, upload_time = "2025-01-10T18:56:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308, upload_time = "2025-01-10T18:56:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960, upload_time = "2025-01-10T18:56:59.539Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803, upload_time = "2025-01-10T18:57:04.919Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929, upload_time = "2025-01-10T18:57:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717, upload_time = "2025-01-10T18:57:12.564Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921, upload_time = "2025-01-10T18:57:17.216Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074, upload_time = "2025-01-10T18:57:20.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093, upload_time = "2025-01-10T18:57:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610, upload_time = "2025-01-10T18:57:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273, upload_time = "2025-01-10T18:57:32.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314, upload_time = "2025-01-10T18:57:35.431Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982, upload_time = "2025-01-10T18:57:38.642Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750, upload_time = "2025-01-10T18:57:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331, upload_time = "2025-01-10T18:57:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708, upload_time = "2025-01-10T18:57:51.308Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "tenacity" version = "8.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/3c/253e1627262373784bf9355db9d6f20d2d8831d79f91e9cca48050cddcc2/tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a", size = 40651 } +sdist = { url = "https://files.pythonhosted.org/packages/89/3c/253e1627262373784bf9355db9d6f20d2d8831d79f91e9cca48050cddcc2/tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a", size = 40651, upload_time = "2023-08-14T13:22:50.869Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/f1/990741d5bb2487d529d20a433210ffa136a367751e454214013b441c4575/tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c", size = 24401 }, + { url = "https://files.pythonhosted.org/packages/f4/f1/990741d5bb2487d529d20a433210ffa136a367751e454214013b441c4575/tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c", size = 24401, upload_time = "2023-08-14T13:22:49.265Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, ] [[package]] name = "tomli-w" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929 } +sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929, upload_time = "2024-10-08T11:13:29.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440 }, + { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440, upload_time = "2024-10-08T11:13:27.897Z" }, ] [[package]] @@ -655,27 +657,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/00/6a9b3aedb0b60a80995ade30f718f1a9902612f22a1aaf531c85a02987f7/tqdm-4.66.3.tar.gz", hash = "sha256:23097a41eba115ba99ecae40d06444c15d1c0c698d527a01c6c8bd1c5d0647e5", size = 169551 } +sdist = { url = "https://files.pythonhosted.org/packages/03/00/6a9b3aedb0b60a80995ade30f718f1a9902612f22a1aaf531c85a02987f7/tqdm-4.66.3.tar.gz", hash = "sha256:23097a41eba115ba99ecae40d06444c15d1c0c698d527a01c6c8bd1c5d0647e5", size = 169551, upload_time = "2024-05-02T21:44:05.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/ad/7d47bbf2cae78ff79f29db0bed5016ec9c56b212a93fca624bb88b551a7c/tqdm-4.66.3-py3-none-any.whl", hash = "sha256:4f41d54107ff9a223dca80b53efe4fb654c67efaba7f47bada3ee9d50e05bd53", size = 78374 }, + { url = "https://files.pythonhosted.org/packages/d1/ad/7d47bbf2cae78ff79f29db0bed5016ec9c56b212a93fca624bb88b551a7c/tqdm-4.66.3-py3-none-any.whl", hash = "sha256:4f41d54107ff9a223dca80b53efe4fb654c67efaba7f47bada3ee9d50e05bd53", size = 78374, upload_time = "2024-05-02T21:44:01.541Z" }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload_time = "2024-06-07T18:52:15.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload_time = "2024-06-07T18:52:13.582Z" }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload_time = "2024-12-22T07:47:30.032Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload_time = "2024-12-22T07:47:28.074Z" }, ] [[package]] @@ -687,7 +689,7 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532 } +sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532, upload_time = "2025-01-03T01:56:53.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719 }, + { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719, upload_time = "2025-01-03T01:56:50.498Z" }, ]