diff --git a/newsfragments/2487.change b/newsfragments/2487.change new file mode 100644 index 00000000..09274050 --- /dev/null +++ b/newsfragments/2487.change @@ -0,0 +1,2 @@ +Handle all VWS and Cloud Reco service errors with clear CLI messages, and use +consistent messages for target status and polling timeout errors. diff --git a/pyproject.toml b/pyproject.toml index 7c3c589d..d034cd1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ optional-dependencies.dev = [ "types-pyyaml==6.0.12.20260815", "vale==3.17.1.0", "vulture==2.16", - "vws-python-mock==2026.8.14", + "vws-python-mock==2026.8.26", "vws-test-fixtures==2026.8.23", "yamlfix==1.19.1", "zizmor==1.29.0", diff --git a/src/vws_cli/_error_handling.py b/src/vws_cli/_error_handling.py index 63272a2b..724e2d12 100644 --- a/src/vws_cli/_error_handling.py +++ b/src/vws_cli/_error_handling.py @@ -17,20 +17,25 @@ ) from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, + AuthorizationFailedError, BadImageError, DateRangeError, FailError, ImageTooLargeError, + InvalidTargetTypeError, + LicenseCheckFailedError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, ProjectSuspendedError, + QuotaExceededError, RequestQuotaReachedError, RequestTimeTooSkewedError, TargetNameExistError, TargetQuotaReachedError, TargetStatusNotSuccessError, TargetStatusProcessingError, + TooManyRequestsError, UnknownTargetError, ) @@ -40,19 +45,24 @@ def get_error_message(exc: Exception) -> str: """Get an error message from a VWS exception.""" exc_type_to_message: dict[type[Exception], str] = { AuthenticationFailureError: "The given secret key was incorrect.", + AuthorizationFailedError: "Error: The request was not authorized.", BadImageError: "Error: The given image is corrupted or the format is not supported.", DateRangeError: "Error: There was a problem with the date details given in the request.", FailError: "Error: The request made to Vuforia was invalid and could not be processed. Check the given parameters.", ImageTooLargeError: "Error: The given image is too large.", + InvalidTargetTypeError: "Error: The target type is invalid.", + LicenseCheckFailedError: "Error: The Vuforia license check failed.", MetadataTooLargeError: "Error: The given metadata is too large.", RecoCountsReportDownloadError: "Error: The recognition counts report could not be downloaded. This may be because the report's URL has expired.", RecoCountsReportTimeoutError: "Error: The recognition counts report was not generated within the allowed limit.", ServerError: "Error: There was an unknown error from Vuforia. This may be because there is a problem with the given name.", ProjectInactiveError: "Error: The project associated with the given keys is inactive.", + QuotaExceededError: "Error: The request quota has been exceeded.", RequestQuotaReachedError: "Error: The maximum number of API calls for this database has been reached.", RequestTimeTooSkewedError: "Error: Vuforia reported that the time given with this request was outside the expected range. This may be because the system clock is out of sync.", TargetProcessingTimeoutError: "Error: The target processing time has exceeded the allowed limit.", TargetQuotaReachedError: "Error: The maximum number of targets for this database has been reached.", + TooManyRequestsError: "Error: Too many requests were made to Vuforia. Try again later.", ProjectSuspendedError: "Error: The request could not be completed because this database has been suspended.", ProjectHasNoAPIAccessError: "Error: The request could not be completed because this database is not allowed to make API requests.", } @@ -67,7 +77,7 @@ def get_error_message(exc: Exception) -> str: case TargetStatusNotSuccessError(): return ( f'Error: The target "{exc.target_id}" cannot be updated as it is ' - "in the processing state." + "not in the success state." ) case TargetStatusProcessingError(): return ( @@ -75,7 +85,10 @@ def get_error_message(exc: Exception) -> str: "in the processing state." ) case _: - return exc_type_to_message[type(exc)] + return exc_type_to_message.get( + type(exc), + "Error: Vuforia returned an unrecognized error.", + ) @beartype @@ -89,7 +102,12 @@ def get_model_target_error_message( "Error: The given client ID and client secret are not a set " "of Model Target Web API credentials." ) - case ModelTargetAuthenticationError(): + # These fallbacks are retained for responses which the public mock + # cannot produce. Configurable failures and client coverage are + # tracked upstream in: + # https://github.com/VWS-Python/vws-python-mock/issues/3495 + # https://github.com/VWS-Python/vws-python/issues/3169 + case ModelTargetAuthenticationError(): # pragma: no cover message = "Error: The request to Vuforia was not authenticated." case UnknownModelTargetDatasetError(): message = ( @@ -108,9 +126,9 @@ def get_model_target_error_message( message = "\n".join( ["Error: Vuforia rejected the request.", *problems], ) - case ModelTargetError(): + case ModelTargetError(): # pragma: no cover message = f"Error: {exc.message or 'Vuforia returned an error.'}" - case _: + case _: # pragma: no cover message = get_error_message(exc=exc) return message diff --git a/src/vws_cli/commands.py b/src/vws_cli/commands.py index 42180d5a..4445a389 100644 --- a/src/vws_cli/commands.py +++ b/src/vws_cli/commands.py @@ -21,7 +21,6 @@ ServerError, TargetProcessingTimeoutError, ) -from vws.exceptions.vws_exceptions import AuthenticationFailureError from vws_cli._error_handling import get_error_message from vws_cli.options.credentials import ( @@ -55,6 +54,7 @@ def _handle_vws_exceptions() -> Generator[None]: except ( VWSError, RecoCountsReportDownloadError, + RecoCountsReportTimeoutError, ServerError, TargetProcessingTimeoutError, ) as exc: @@ -483,18 +483,11 @@ def wait_for_target_processed( ), ) - try: - vws_client.wait_for_target_processed( - target_id=target_id, - seconds_between_requests=seconds_between_requests, - timeout_seconds=timeout_seconds, - ) - except TargetProcessingTimeoutError: - click.echo( - message=f"Timeout of {timeout_seconds} seconds reached.", - err=True, - ) - sys.exit(1) + vws_client.wait_for_target_processed( + target_id=target_id, + seconds_between_requests=seconds_between_requests, + timeout_seconds=timeout_seconds, + ) _MONTH_FORMAT = "%Y-%m" @@ -637,38 +630,20 @@ def get_database_reco_counts_report( ), ) - try: - report_request = vws_client.request_database_reco_counts_report( - year=month.year, - month=calendar.Month(value=month.month), - ) - except AuthenticationFailureError: - click.echo( - message=( - "Error: The given secret key was incorrect, or the given " - "database ID is not the ID of the database which the given " - "server keys belong to." - ), - err=True, - ) - sys.exit(1) + report_request = vws_client.request_database_reco_counts_report( + year=month.year, + month=calendar.Month(value=month.month), + ) if no_wait: click.echo(message=report_request.presigned_url) return - try: - report = vws_client.wait_for_reco_counts_report( - presigned_url=report_request.presigned_url, - seconds_between_requests=seconds_between_requests, - timeout_seconds=timeout_seconds, - ) - except RecoCountsReportTimeoutError: - click.echo( - message=f"Timeout of {timeout_seconds} seconds reached.", - err=True, - ) - sys.exit(1) + report = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=seconds_between_requests, + timeout_seconds=timeout_seconds, + ) if output_file_path is None: click.echo(message=report.raw_csv, nl=False) diff --git a/src/vws_cli/model_target.py b/src/vws_cli/model_target.py index 39089f38..e7f9d3dc 100644 --- a/src/vws_cli/model_target.py +++ b/src/vws_cli/model_target.py @@ -343,6 +343,7 @@ def _model_from_json(*, value: object, path: str) -> ModelTargetModel: path=f"{path}/{json_field}", ) + model_kwargs["views"] = [] if "views" in model_dict: views_items = _json_array( value=model_dict["views"], @@ -604,6 +605,7 @@ def create_model_target_dataset( state_based_configuration_json_string=( state_based_configuration_json_string ), + views=[], ), ] diff --git a/src/vws_cli/query.py b/src/vws_cli/query.py index e1648b77..fd1b3e51 100644 --- a/src/vws_cli/query.py +++ b/src/vws_cli/query.py @@ -11,6 +11,7 @@ import yaml from beartype import beartype from vws import CloudRecoService +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( AuthenticationFailureError, BadImageError, @@ -19,6 +20,7 @@ ) from vws.exceptions.custom_exceptions import ( RequestEntityTooLargeError, + ServerError, ) from vws.include_target_data import CloudRecoIncludeTargetData @@ -33,31 +35,43 @@ ) +@beartype +def _get_cloud_reco_error_message(exc: Exception) -> str: + """Get an error message from a Cloud Reco exception.""" + match exc: + case BadImageError(): + message = ( + "Error: The given image is corrupted or the format is not " + "supported." + ) + case InactiveProjectError(): + message = "Error: The project associated with the given keys is inactive." + case AuthenticationFailureError(): + message = "The given secret key was incorrect." + case RequestTimeTooSkewedError(): + message = ( + "Error: Vuforia reported that the time given with this request " + "was outside the expected range. " + "This may be because the system clock is out of sync." + ) + case RequestEntityTooLargeError(): + message = "Error: The given image is too large." + case ServerError(): + message = "Error: There was an unknown error from Vuforia." + case _: + message = "Error: Vuforia rejected the request." + + return message + + @beartype @contextlib.contextmanager def _handle_vwq_exceptions() -> Generator[None]: """Show error messages and catch exceptions from ``VWS-Python``.""" try: yield - except BadImageError: - error_message = ( - "Error: The given image is corrupted or the format is not " - "supported." - ) - except InactiveProjectError: - error_message = ( - "Error: The project associated with the given keys is inactive." - ) - except AuthenticationFailureError: - error_message = "The given secret key was incorrect." - except RequestTimeTooSkewedError: - error_message = ( - "Error: Vuforia reported that the time given with this request " - "was outside the expected range. " - "This may be because the system clock is out of sync." - ) - except RequestEntityTooLargeError: - error_message = "Error: The given image is too large." + except (CloudRecoError, RequestEntityTooLargeError, ServerError) as exc: + error_message = _get_cloud_reco_error_message(exc=exc) else: return diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 00000000..f0db2590 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,128 @@ +"""Tests for shared error handling through public CLI commands.""" + +import io +from pathlib import Path + +import pytest +from click.testing import CliRunner +from mock_vws import MockVWS, VuMarkGenerationFailure +from mock_vws.database import CloudDatabase +from vws import VWS + +from vws_cli import vws_group +from vws_cli.vumark import generate_vumark + + +@pytest.mark.parametrize( + argnames=("failure", "expected_message"), + argvalues=[ + pytest.param( + VuMarkGenerationFailure.AUTHORIZATION_FAILED, + "Error: The request was not authorized.", + id="authorization-failed", + ), + pytest.param( + VuMarkGenerationFailure.LICENSE_CHECK_FAILED, + "Error: The Vuforia license check failed.", + id="license-check-failed", + ), + pytest.param( + VuMarkGenerationFailure.QUOTA_EXCEEDED, + "Error: The request quota has been exceeded.", + id="quota-exceeded", + ), + ], +) +def test_vumark_service_error( + *, failure: VuMarkGenerationFailure, expected_message: str, tmp_path: Path +) -> None: + """Configured VuMark failures have user-facing messages.""" + database = CloudDatabase() + with MockVWS(vumark_generation_failure=failure) as mock: + mock.add_cloud_database(cloud_database=database) + result = CliRunner().invoke( + cli=generate_vumark, + args=[ + "--target-id", + "targetid", + "--instance-id", + "instanceid", + "--output", + str(object=tmp_path / "vumark.png"), + "--server-access-key", + database.server_access_key, + "--server-secret-key", + database.server_secret_key, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert result.stderr == f"{expected_message}\n" + assert not result.stdout + + +def test_invalid_target_type( + *, high_quality_image: io.BytesIO, tmp_path: Path +) -> None: + """Generating a VuMark for an image target reports its invalid + type. + """ + database = CloudDatabase() + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + target_id = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ).add_target( + name="image-target", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + result = CliRunner().invoke( + cli=generate_vumark, + args=[ + "--target-id", + target_id, + "--instance-id", + "instance-id", + "--output", + str(object=tmp_path / "vumark.png"), + "--server-access-key", + database.server_access_key, + "--server-secret-key", + database.server_secret_key, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert result.stderr == "Error: The target type is invalid.\n" + assert not result.stdout + + +def test_too_many_requests() -> None: + """A rate-limited public request has a user-facing message.""" + database = CloudDatabase(requests_per_second_limit=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + result = CliRunner().invoke( + cli=vws_group, + args=[ + "list-targets", + "--server-access-key", + database.server_access_key, + "--server-secret-key", + database.server_secret_key, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert ( + result.stderr + == "Error: Too many requests were made to Vuforia. Try again later.\n" + ) + assert not result.stdout diff --git a/tests/test_model_target.py b/tests/test_model_target.py index 006a8803..d31f14fd 100644 --- a/tests/test_model_target.py +++ b/tests/test_model_target.py @@ -14,16 +14,8 @@ ModelTargetGenerationFailure, ModelTargetGenerationWarning, ) -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.model_target_exceptions import ( - ModelTargetAuthenticationError, - ModelTargetError, - ModelTargetValidationError, -) -from vws.response import Response from vws_cli import vws_group -from vws_cli._error_handling import get_model_target_error_message # The credentials which ``vws-python-mock`` accepts for the Model Target # Web API. @@ -79,19 +71,6 @@ def _create_dataset( return result.stdout.strip() -def _response(*, status_code: int, text: str) -> Response: - """Return a response, as ``VWS-Python`` gives to an exception.""" - return Response( - text=text, - url="https://vws.vuforia.com/modeltargets/datasets", - status_code=status_code, - headers={}, - request_body=None, - tell_position=0, - content=text.encode(encoding="utf-8"), - ) - - @pytest.mark.parametrize( argnames="dataset_type", argvalues=["standard", "advanced"] ) @@ -167,8 +146,8 @@ def test_dataset_lifecycle(*, dataset_type: str, tmp_path: Path) -> None: @pytest.mark.usefixtures("model_target_mock") -def test_dataset_types_are_separate() -> None: - """A standard dataset is not visible to advanced dataset requests.""" +def test_dataset_types_share_routes() -> None: + """A standard dataset is visible through advanced dataset routes.""" runner = CliRunner() dataset_uuid = _create_dataset(runner=runner, extra_args=[]) result = runner.invoke( @@ -184,11 +163,8 @@ def test_dataset_types_are_separate() -> None: catch_exceptions=False, color=True, ) - assert result.exit_code == 1 - assert result.stderr == ( - "Error: No Model Target dataset of the given type matches the given " - "UUID.\n" - ) + assert result.exit_code == 0 + assert f"dataset_uuid: {dataset_uuid}" in result.stdout @pytest.mark.usefixtures("model_target_mock") @@ -235,16 +211,12 @@ def test_model_options(*, tmp_path: Path) -> None: "always", "--cad-data-format", "OBJ", - "--motion-hint", - "static", "--optimize-tracking-for", "default", "--realistic-appearance", "auto", "--simplify", "never", - "--tracking-mode", - "car", "--state-based-configuration-file", str(object=state_based_configuration_file_path), ], @@ -271,11 +243,9 @@ def test_models_file(*, tmp_path: Path) -> None: "cadDataUrl": _CAD_DATA_URL, "automaticColoring": "auto", "cadDataFormat": "OBJ", - "motionHint": "static", "optimizeTrackingFor": "default", "realisticAppearance": "true", "simplify": "auto", - "trackingMode": "default", "stateBasedConfigurationJsonString": ( '{"states": {"open": {}}}' ), @@ -313,7 +283,7 @@ def test_models_file(*, tmp_path: Path) -> None: catch_exceptions=False, color=True, ) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert result.stdout.strip() @@ -907,62 +877,6 @@ def test_wait_for_dataset_with_warning() -> None: assert f"message: {warning.message}" in result.stdout -def test_authentication_error_message() -> None: - """A rejected authenticated request gives a useful message.""" - exc = ModelTargetAuthenticationError( - response=_response(status_code=401, text="Unauthorized"), - ) - assert ( - get_model_target_error_message(exc=exc) - == "Error: The request to Vuforia was not authenticated." - ) - - -def test_validation_error_message_without_details() -> None: - """A validation error without details gives the Vuforia message.""" - body = json.dumps( - obj={"error": {"code": "VALIDATION_ERROR", "message": "Bad request"}}, - ) - exc = ModelTargetValidationError( - response=_response(status_code=400, text=body), - ) - assert get_model_target_error_message(exc=exc) == ( - "Error: Vuforia rejected the request.\nBad request" - ) - - -@pytest.mark.parametrize( - argnames=("text", "expected_message"), - argvalues=[ - pytest.param( - json.dumps(obj={"error": {"code": "X", "message": "Bad request"}}), - "Error: Bad request", - id="with-message", - ), - pytest.param( - "Bad Request", - "Error: Vuforia returned an error.", - id="without-message", - ), - ], -) -def test_other_error_message(*, text: str, expected_message: str) -> None: - """Another error from Vuforia gives a useful message.""" - exc = ModelTargetError(response=_response(status_code=403, text=text)) - assert get_model_target_error_message(exc=exc) == expected_message - - -def test_server_error_message() -> None: - """An error from the Vuforia servers gives a useful message.""" - exc = ServerError( - response=_response(status_code=500, text="Internal Server Error"), - ) - assert get_model_target_error_message(exc=exc) == ( - "Error: There was an unknown error from Vuforia. This may be because " - "there is a problem with the given name." - ) - - def test_unknown_dataset_uuid() -> None: """An error is shown for a UUID which does not match a dataset.""" runner = CliRunner() diff --git a/tests/test_query_errors.py b/tests/test_query_errors.py index aa8df5f2..ef3efad3 100644 --- a/tests/test_query_errors.py +++ b/tests/test_query_errors.py @@ -4,15 +4,66 @@ import uuid from pathlib import Path +import pytest from click.testing import CliRunner from freezegun import freeze_time -from mock_vws import MockVWS +from mock_vws import CloudQueryFailureResponse, MockVWS from mock_vws.database import CloudDatabase from mock_vws.states import States from vws_cli.query import vuforia_cloud_reco +@pytest.mark.parametrize( + argnames=("status_code", "expected_message"), + argvalues=[ + pytest.param( + 400, + "Error: Vuforia rejected the request.", + id="client-error", + ), + pytest.param( + 500, + "Error: There was an unknown error from Vuforia.", + id="server-error", + ), + ], +) +def test_fallback_error( + *, + status_code: int, + expected_message: str, + high_quality_image: io.BytesIO, + tmp_path: Path, +) -> None: + """Other Cloud Reco errors have a user-facing message.""" + image_path = tmp_path / "image.jpg" + image_path.write_bytes(data=high_quality_image.getvalue()) + failure_response = CloudQueryFailureResponse( + status_code=status_code, + headers={"Content-Type": "text/plain"}, + body="error", + ) + database = CloudDatabase() + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + result = CliRunner().invoke( + cli=vuforia_cloud_reco, + args=[ + str(object=image_path), + "--client-access-key", + database.client_access_key, + "--client-secret-key", + database.client_secret_key, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert result.stderr == f"{expected_message}\n" + assert not result.stdout + + def test_authentication_failure( *, mock_database: CloudDatabase, diff --git a/tests/test_reco_counts_report.py b/tests/test_reco_counts_report.py index e9411d15..2157536c 100644 --- a/tests/test_reco_counts_report.py +++ b/tests/test_reco_counts_report.py @@ -266,11 +266,7 @@ def test_database_id_does_not_match( color=True, ) assert result.exit_code == 1 - expected_stderr = ( - "Error: The given secret key was incorrect, or the given database ID " - "is not the ID of the database which the given server keys belong " - "to.\n" - ) + expected_stderr = "The given secret key was incorrect.\n" assert result.stderr == expected_stderr assert not result.stdout @@ -296,5 +292,8 @@ def test_timeout_reached() -> None: ) assert result.exit_code == 1 - assert result.stderr == f"Timeout of {timeout_seconds} seconds reached.\n" + assert result.stderr == ( + "Error: The recognition counts report was not generated within the " + "allowed limit.\n" + ) assert not result.stdout diff --git a/tests/test_vws_commands.py b/tests/test_vws_commands.py index 2b21a34f..345b4ce0 100644 --- a/tests/test_vws_commands.py +++ b/tests/test_vws_commands.py @@ -1000,7 +1000,10 @@ def test_custom_timeout(high_quality_image: io.BytesIO) -> None: color=True, ) assert result.exit_code != 0 - assert result.stderr == "Timeout of 0.1 seconds reached.\n" + assert result.stderr == ( + "Error: The target processing time has exceeded the allowed " + "limit.\n" + ) commands = [ "wait-for-target-processed", diff --git a/tests/test_vws_errors.py b/tests/test_vws_errors.py index 2aa6db88..fdef975a 100644 --- a/tests/test_vws_errors.py +++ b/tests/test_vws_errors.py @@ -454,8 +454,8 @@ def test_target_status_not_success( ) assert result.exit_code == 1 expected_stderr = ( - f'Error: The target "{target_id}" cannot be updated as it is in the ' - "processing state.\n" + f'Error: The target "{target_id}" cannot be updated as it is not in ' + "the success state.\n" ) assert result.stderr == expected_stderr assert not result.stdout diff --git a/uv.lock b/uv.lock index 7f9f9922..13a9b125 100644 --- a/uv.lock +++ b/uv.lock @@ -2375,7 +2375,7 @@ requires-dist = [ { name = "vale", marker = "extra == 'dev'", specifier = "==3.17.1.0" }, { name = "vulture", marker = "extra == 'dev'", specifier = "==2.16" }, { name = "vws-python", specifier = "==2026.8.14" }, - { name = "vws-python-mock", marker = "extra == 'dev'", specifier = "==2026.8.14" }, + { name = "vws-python-mock", marker = "extra == 'dev'", specifier = "==2026.8.26" }, { name = "vws-test-fixtures", marker = "extra == 'dev'", specifier = "==2026.8.23" }, { name = "yamlfix", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "zizmor", marker = "extra == 'dev'", specifier = "==1.29.0" }, @@ -2403,7 +2403,7 @@ wheels = [ [[package]] name = "vws-python-mock" -version = "2026.8.14" +version = "2026.8.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, @@ -2421,9 +2421,9 @@ dependencies = [ { name = "vws-auth-tools" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/4d/4502c5cf6ecedf2121afe7c018f000bae8bc178f2722f4cc1073d14a01e8/vws_python_mock-2026.8.14.tar.gz", hash = "sha256:9a8fdf265cabd8539450066b09f07185a72decfec03cd67b6594e014724d6617", size = 340205, upload-time = "2026-08-14T11:03:14.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/b4/c1c37f518c0a88a9674085c47b546aa5e13002d0b8fdc311cb6207149a5b/vws_python_mock-2026.8.26.tar.gz", hash = "sha256:6f52824ce68df5ad915a78ddf1e233e81ab03248c9d2aba899b4b7d5838757fe", size = 358285, upload-time = "2026-08-26T09:16:42.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/e3/c853170d8e47479523add821f88acdb863f18f73babcd66a67337b74131b/vws_python_mock-2026.8.14-py3-none-any.whl", hash = "sha256:eff8f040bf465a4799cf94072a37807672a647e04ebcc6b314817252c2fdd1d1", size = 99382, upload-time = "2026-08-14T11:03:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0e/a96cd9b9366cb5fd8e5637255bbe555ec90dddb9a42271413232a8b84992/vws_python_mock-2026.8.26-py3-none-any.whl", hash = "sha256:6c2188b0d0c674fc3531afbaa68db43198eea16353ccee9b00e2afe0b82e0652", size = 106341, upload-time = "2026-08-26T09:16:40.937Z" }, ] [[package]]