From 4d3297d6937fca3746da5e501de7107e1e918f42 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 14 Aug 2026 15:53:44 +0100 Subject: [PATCH] Add a Database Reco Counts report command Add ``vws get-database-reco-counts-report``, which requests a per-target recognition counts report for a month, waits for Vuforia to generate it, and writes the CSV to stdout or to a path given by ``--output``. The report is a table rather than a dataclass, so the command writes the raw CSV instead of dumping YAML as the sibling report commands do. It waits for the report by default, in the style of ``wait-for-target-processed``, because the download 404s until Vuforia has finished generating the report; ``--no-wait`` opts out and shows the presigned URL instead. ``--month`` takes a ``YYYY-mm`` value and defaults to the current month. ``--database-id`` is needed by this endpoint and no other, so it is a new required option with a ``VUFORIA_DATABASE_ID`` environment variable alongside the credential options. Closes #2441. Closes #2442. Co-Authored-By: Claude Opus 5 (1M context) --- newsfragments/2441.change.rst | 1 + spelling_private_dict.txt | 2 + src/vws_cli/__init__.py | 2 + src/vws_cli/_error_handling.py | 4 + src/vws_cli/commands.py | 189 ++++++++++- src/vws_cli/options/vws.py | 18 ++ tests/test_help/test_vws_command_help____.txt | 20 +- ...p___get_database_reco_counts_report___.txt | 50 +++ tests/test_reco_counts_report.py | 300 ++++++++++++++++++ 9 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 newsfragments/2441.change.rst create mode 100644 tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt create mode 100644 tests/test_reco_counts_report.py diff --git a/newsfragments/2441.change.rst b/newsfragments/2441.change.rst new file mode 100644 index 00000000..11c566cb --- /dev/null +++ b/newsfragments/2441.change.rst @@ -0,0 +1 @@ +Add a ``vws get-database-reco-counts-report`` command, which requests a per-target recognition counts report for a month, waits for Vuforia to generate it, and writes the CSV to stdout or to a given path. diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index fda07bcc..b5bcfbf3 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,3 +1,4 @@ +CSV Winget admin api @@ -33,6 +34,7 @@ reportMissingTypeStubs reportUnknownArgumentType reportUnknownMemberType reportUnknownVariableType +stdout svg typeshed ubuntu diff --git a/src/vws_cli/__init__.py b/src/vws_cli/__init__.py index 7837a425..eb2d4914 100644 --- a/src/vws_cli/__init__.py +++ b/src/vws_cli/__init__.py @@ -8,6 +8,7 @@ from vws_cli.commands import ( add_target, delete_target, + get_database_reco_counts_report, get_database_summary_report, get_duplicate_targets, get_target_record, @@ -41,6 +42,7 @@ def vws_group() -> None: vws_group.add_command(cmd=add_target) vws_group.add_command(cmd=delete_target) +vws_group.add_command(cmd=get_database_reco_counts_report) vws_group.add_command(cmd=get_database_summary_report) vws_group.add_command(cmd=get_duplicate_targets) vws_group.add_command(cmd=get_target_record) diff --git a/src/vws_cli/_error_handling.py b/src/vws_cli/_error_handling.py index f5fe7ce9..bbecb570 100644 --- a/src/vws_cli/_error_handling.py +++ b/src/vws_cli/_error_handling.py @@ -2,6 +2,8 @@ from beartype import beartype from vws.exceptions.custom_exceptions import ( + RecoCountsReportDownloadError, + RecoCountsReportTimeoutError, ServerError, TargetProcessingTimeoutError, ) @@ -35,6 +37,8 @@ def get_error_message(exc: Exception) -> str: 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.", 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.", RequestQuotaReachedError: "Error: The maximum number of API calls for this database has been reached.", diff --git a/src/vws_cli/commands.py b/src/vws_cli/commands.py index 9f2ddd2a..42180d5a 100644 --- a/src/vws_cli/commands.py +++ b/src/vws_cli/commands.py @@ -1,11 +1,14 @@ """``click`` commands the VWS CLI.""" +import calendar import contextlib import dataclasses +import datetime import io import sys from collections.abc import Generator from pathlib import Path +from zoneinfo import ZoneInfo import click import yaml @@ -13,9 +16,12 @@ from vws import VWS from vws.exceptions.base_exceptions import VWSError from vws.exceptions.custom_exceptions import ( + RecoCountsReportDownloadError, + RecoCountsReportTimeoutError, ServerError, TargetProcessingTimeoutError, ) +from vws.exceptions.vws_exceptions import AuthenticationFailureError from vws_cli._error_handling import get_error_message from vws_cli.options.credentials import ( @@ -35,7 +41,7 @@ connection_timeout_seconds_option, read_timeout_seconds_option, ) -from vws_cli.options.vws import base_vws_url_option +from vws_cli.options.vws import base_vws_url_option, database_id_option @beartype @@ -48,6 +54,7 @@ def _handle_vws_exceptions() -> Generator[None]: yield except ( VWSError, + RecoCountsReportDownloadError, ServerError, TargetProcessingTimeoutError, ) as exc: @@ -488,3 +495,183 @@ def wait_for_target_processed( err=True, ) sys.exit(1) + + +_MONTH_FORMAT = "%Y-%m" + +_REPORT_SECONDS_BETWEEN_REQUESTS_HELP = ( + "The number of seconds to wait between requests made while polling for " + "the report. " + f"We wait {_SECONDS_BETWEEN_REQUESTS_DEFAULT} seconds by default, rather " + "than less, than that to decrease the number of calls made to the API, to " + "decrease the likelihood of hitting the request quota." +) + +_REPORT_TIMEOUT_SECONDS_HELP = ( + "The maximum number of seconds to wait for the report to be generated." +) + + +@beartype +def _default_month() -> str: + """Return the current month, in the form which ``--month`` takes.""" + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + return now.strftime(format=_MONTH_FORMAT) + + +@beartype +def _validate_month( + ctx: click.Context, + param: click.Parameter, + value: str, +) -> datetime.date: + """Turn a ``YYYY-mm`` string into the first day of that month.""" + # These are given by ``click``, and we do not use them. + del ctx + del param + try: + parsed = datetime.datetime.strptime( # noqa: DTZ007 + value, + _MONTH_FORMAT, + ) + except ValueError as exc: + message = f'"{value}" is not a month in the YYYY-mm form.' + raise click.BadParameter(message=message) from exc + return parsed.date() + + +@click.command(name="get-database-reco-counts-report") +@click.option( + "--month", + type=str, + default=_default_month, + callback=_validate_month, + help=( + "The month to get recognition counts for, in the YYYY-mm form. " + "Vuforia accepts only the current month and the previous month." + ), + show_default="the current month", +) +@click.option( + "--output", + "output_file_path", + type=click.Path( + dir_okay=False, + writable=True, + path_type=Path, + ), + required=False, + help=( + "The path to write the CSV report to. By default, the report is " + "written to stdout." + ), +) +@click.option( + "--no-wait", + "no_wait", + is_flag=True, + default=False, + help=( + "Do not wait for the report to be generated. Instead, show the URL " + "to download the report from once it has been generated." + ), +) +@click.option( + "--seconds-between-requests", + type=click.FloatRange(min=0.05), + default=_SECONDS_BETWEEN_REQUESTS_DEFAULT, + help=_REPORT_SECONDS_BETWEEN_REQUESTS_HELP, + show_default=True, +) +@click.option( + "--timeout-seconds", + type=click.FloatRange(min=0.05), + default=300, + help=_REPORT_TIMEOUT_SECONDS_HELP, + show_default=True, +) +@server_access_key_option +@server_secret_key_option +@database_id_option +@base_vws_url_option +@connection_timeout_seconds_option +@read_timeout_seconds_option +@_handle_vws_exceptions() +@beartype +def get_database_reco_counts_report( + *, + server_access_key: str, + server_secret_key: str, + database_id: str, + month: datetime.date, + output_file_path: Path | None, + no_wait: bool, + seconds_between_requests: float, + timeout_seconds: float, + base_vws_url: str, + connection_timeout_seconds: float, + read_timeout_seconds: float, +) -> None: + """Get a per-target recognition counts report for a database. + + The report is a CSV with a ``target_id,reco_count`` header. Vuforia + generates the report in the background, so by default we wait for the + report to be generated before downloading it. + + \b + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + """ + if no_wait and output_file_path is not None: + message = "--output cannot be used with --no-wait." + raise click.UsageError(message=message) + + vws_client = VWS( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + base_vws_url=base_vws_url, + database_id=database_id, + request_timeout_seconds=( + connection_timeout_seconds, + read_timeout_seconds, + ), + ) + + 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) + + 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) + + if output_file_path is None: + click.echo(message=report.raw_csv, nl=False) + return + + output_file_path.write_bytes(data=report.raw_csv) diff --git a/src/vws_cli/options/vws.py b/src/vws_cli/options/vws.py index ea976334..c944d58d 100644 --- a/src/vws_cli/options/vws.py +++ b/src/vws_cli/options/vws.py @@ -7,6 +7,24 @@ from beartype import beartype +@beartype +def database_id_option( + command: Callable[..., Any], +) -> Callable[..., Any]: + """An option decorator for the Vuforia database ID.""" + return click.option( + "--database-id", + type=str, + help=( + "The ID of the Vuforia database which the given server keys " + "belong to. This is shown in the Vuforia target manager." + ), + required=True, + envvar="VUFORIA_DATABASE_ID", + show_envvar=True, + )(command) + + @beartype def base_vws_url_option( command: Callable[..., Any], diff --git a/tests/test_help/test_vws_command_help____.txt b/tests/test_help/test_vws_command_help____.txt index 13ff9f18..1a1a4c49 100644 --- a/tests/test_help/test_vws_command_help____.txt +++ b/tests/test_help/test_vws_command_help____.txt @@ -7,12 +7,14 @@ Options: -h, --help Show this message and exit. Commands: - add-target Add a target. - delete-target Delete a target. - get-database-summary-report Get a database summary report. - get-duplicate-targets Get a list of potential duplicate targets. - get-target-record Get a target record. - get-target-summary-report Get a target summary report. - list-targets List targets. - update-target Update a target. - wait-for-target-processed Wait for a target to be "processed". + add-target Add a target. + delete-target Delete a target. + get-database-reco-counts-report + Get a per-target recognition counts... + get-database-summary-report Get a database summary report. + get-duplicate-targets Get a list of potential duplicate targets. + get-target-record Get a target record. + get-target-summary-report Get a target summary report. + list-targets List targets. + update-target Update a target. + wait-for-target-processed Wait for a target to be "processed". diff --git a/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt b/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt new file mode 100644 index 00000000..2cd38898 --- /dev/null +++ b/tests/test_help/test_vws_command_help___get_database_reco_counts_report___.txt @@ -0,0 +1,50 @@ +Usage: vws get-database-reco-counts-report [OPTIONS] + + Get a per-target recognition counts report for a database. + + The report is a CSV with a ``target_id,reco_count`` header. Vuforia generates + the report in the background, so by default we wait for the report to be + generated before downloading it. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + +Options: + --month TEXT The month to get recognition counts for, in + the YYYY-mm form. Vuforia accepts only the + current month and the previous month. + [default: (the current month)] + --output FILE The path to write the CSV report to. By + default, the report is written to stdout. + --no-wait Do not wait for the report to be generated. + Instead, show the URL to download the report + from once it has been generated. + --seconds-between-requests FLOAT RANGE + The number of seconds to wait between requests + made while polling for the report. We wait 0.2 + seconds by default, rather than less, than + that to decrease the number of calls made to + the API, to decrease the likelihood of hitting + the request quota. [default: 0.2; x>=0.05] + --timeout-seconds FLOAT RANGE The maximum number of seconds to wait for the + report to be generated. [default: 300; + x>=0.05] + --server-access-key TEXT A Vuforia server access key to use to access + the Vuforia Web Services API. [env var: + VUFORIA_SERVER_ACCESS_KEY; required] + --server-secret-key TEXT A Vuforia server secret key to use to access + the Vuforia Web Services API. [env var: + VUFORIA_SERVER_SECRET_KEY; required] + --database-id TEXT The ID of the Vuforia database which the given + server keys belong to. This is shown in the + Vuforia target manager. [env var: + VUFORIA_DATABASE_ID; required] + --base-vws-url TEXT The base URL for the VWS API. [default: + https://vws.vuforia.com] + --connection-timeout-seconds FLOAT RANGE + The connection timeout for HTTP requests, in + seconds. [default: 30; x>=0.05] + --read-timeout-seconds FLOAT RANGE + The read timeout for HTTP requests, in + seconds. [default: 30; x>=0.05] + -h, --help Show this message and exit. diff --git a/tests/test_reco_counts_report.py b/tests/test_reco_counts_report.py new file mode 100644 index 00000000..e9411d15 --- /dev/null +++ b/tests/test_reco_counts_report.py @@ -0,0 +1,300 @@ +"""Tests for the ``get-database-reco-counts-report`` command.""" + +import datetime +import uuid +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest +from click.testing import CliRunner +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase +from vws import VWS +from vws.exceptions.custom_exceptions import RecoCountsReportNotReadyError + +from vws_cli import vws_group + +_EXPECTED_CSV = b"target_id,reco_count\r\n" + +# The exit code which ``click`` uses for a usage error. +_USAGE_ERROR_EXIT_CODE = 2 + + +def _month_string(*, months_ago: int) -> str: + """Return a ``YYYY-mm`` string for a month relative to this month.""" + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + first_of_month = now.replace(day=1) + for _ in range(months_ago): + first_of_month = first_of_month.replace(day=1) - datetime.timedelta( + days=1, + ) + return first_of_month.strftime(format="%Y-%m") + + +def _base_commands(*, mock_database: CloudDatabase) -> list[str]: + """Return the command and credential arguments for the report + command. + """ + return [ + "get-database-reco-counts-report", + "--server-access-key", + mock_database.server_access_key, + "--server-secret-key", + mock_database.server_secret_key, + "--database-id", + mock_database.database_id, + ] + + +def test_get_database_reco_counts_report( + *, + mock_database: CloudDatabase, +) -> None: + """The report is written to stdout. + + The ``mock_database`` fixture does not make the report available + immediately, so this also shows that the command waits for the report. + """ + runner = CliRunner() + result = runner.invoke( + cli=vws_group, + args=_base_commands(mock_database=mock_database), + catch_exceptions=False, + color=True, + ) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout_bytes == _EXPECTED_CSV + + +def test_report_is_not_available_immediately() -> None: + """The command waits for a report which is not ready to download.""" + runner = CliRunner() + mock_database = CloudDatabase() + with MockVWS(processing_time_seconds=1) as mock: + mock.add_cloud_database(cloud_database=mock_database) + vws_client = VWS( + server_access_key=mock_database.server_access_key, + server_secret_key=mock_database.server_secret_key, + ) + no_wait_result = runner.invoke( + cli=vws_group, + args=[*_base_commands(mock_database=mock_database), "--no-wait"], + catch_exceptions=False, + color=True, + ) + assert no_wait_result.exit_code == 0 + presigned_url = no_wait_result.stdout.strip() + with pytest.raises(expected_exception=RecoCountsReportNotReadyError): + vws_client.download_reco_counts_report( + presigned_url=presigned_url, + ) + + result = runner.invoke( + cli=vws_group, + args=_base_commands(mock_database=mock_database), + catch_exceptions=False, + color=True, + ) + + assert result.exit_code == 0 + assert result.stdout_bytes == _EXPECTED_CSV + + +def test_output_file(*, tmp_path: Path) -> None: + """The report is written to the path given by ``--output``.""" + runner = CliRunner() + mock_database = CloudDatabase() + output_file_path = tmp_path / uuid.uuid4().hex + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=mock_database) + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--output", + str(object=output_file_path), + ], + catch_exceptions=False, + color=True, + ) + + assert result.exit_code == 0 + assert not result.stdout + assert output_file_path.read_bytes() == _EXPECTED_CSV + + +def test_no_wait() -> None: + """``--no-wait`` shows the URL to download the report from.""" + runner = CliRunner() + mock_database = CloudDatabase() + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=mock_database) + vws_client = VWS( + server_access_key=mock_database.server_access_key, + server_secret_key=mock_database.server_secret_key, + ) + result = runner.invoke( + cli=vws_group, + args=[*_base_commands(mock_database=mock_database), "--no-wait"], + catch_exceptions=False, + color=True, + ) + assert result.exit_code == 0 + presigned_url = result.stdout.strip() + report = vws_client.download_reco_counts_report( + presigned_url=presigned_url, + ) + + assert report.raw_csv == _EXPECTED_CSV + + +def test_no_wait_with_output_file( + *, + mock_database: CloudDatabase, + tmp_path: Path, +) -> None: + """``--output`` cannot be used with ``--no-wait``.""" + runner = CliRunner() + output_file_path = tmp_path / uuid.uuid4().hex + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--no-wait", + "--output", + str(object=output_file_path), + ], + catch_exceptions=False, + color=True, + ) + assert result.exit_code == _USAGE_ERROR_EXIT_CODE + assert "--output cannot be used with --no-wait." in result.stderr + assert not output_file_path.exists() + + +def test_previous_month() -> None: + """A report can be requested for the previous month.""" + runner = CliRunner() + mock_database = CloudDatabase() + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=mock_database) + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--month", + _month_string(months_ago=1), + ], + catch_exceptions=False, + color=True, + ) + + assert result.exit_code == 0 + assert result.stdout_bytes == _EXPECTED_CSV + + +def test_month_is_not_in_the_yyyy_mm_form( + *, + mock_database: CloudDatabase, +) -> None: + """An error is shown for a month which is not in the ``YYYY-mm`` + form. + """ + runner = CliRunner() + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--month", + "not-a-month", + ], + catch_exceptions=False, + color=True, + ) + assert result.exit_code == _USAGE_ERROR_EXIT_CODE + expected_message = '"not-a-month" is not a month in the YYYY-mm form.' + assert expected_message in result.stderr + + +def test_month_out_of_range(*, mock_database: CloudDatabase) -> None: + """An error is shown for a month which Vuforia does not accept. + + Vuforia accepts only the current month and the previous month. + """ + runner = CliRunner() + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--month", + _month_string(months_ago=2), + ], + catch_exceptions=False, + color=True, + ) + assert result.exit_code == 1 + expected_stderr = ( + "Error: The request made to Vuforia was invalid and could not be " + "processed. Check the given parameters.\n" + ) + assert result.stderr == expected_stderr + assert not result.stdout + + +def test_database_id_does_not_match( + *, + mock_database: CloudDatabase, +) -> None: + """An error is shown when the given database ID is not the + database's. + """ + runner = CliRunner() + commands = [ + "get-database-reco-counts-report", + "--server-access-key", + mock_database.server_access_key, + "--server-secret-key", + mock_database.server_secret_key, + "--database-id", + uuid.uuid4().hex, + ] + result = runner.invoke( + cli=vws_group, + args=commands, + catch_exceptions=False, + 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" + ) + assert result.stderr == expected_stderr + assert not result.stdout + + +def test_timeout_reached() -> None: + """An error is shown when the report is not generated in time.""" + runner = CliRunner() + mock_database = CloudDatabase() + timeout_seconds = 0.1 + with MockVWS(processing_time_seconds=60) as mock: + mock.add_cloud_database(cloud_database=mock_database) + result = runner.invoke( + cli=vws_group, + args=[ + *_base_commands(mock_database=mock_database), + "--timeout-seconds", + str(object=timeout_seconds), + "--seconds-between-requests", + "0.05", + ], + catch_exceptions=False, + color=True, + ) + + assert result.exit_code == 1 + assert result.stderr == f"Timeout of {timeout_seconds} seconds reached.\n" + assert not result.stdout