From 45ff719e83d9922025a7ece7feaf9b3becb29bc4 Mon Sep 17 00:00:00 2001 From: Stefan Scholz Date: Tue, 20 Oct 2026 12:03:58 +0200 Subject: [PATCH] adapt package to reorganized backend --- .env.example | 2 +- CONTRIBUTING.md | 24 +- README.md | 14 +- pyproject.toml | 4 +- ...reate_activity.py => create_prediction.py} | 26 +- scripts/create_space.py | 30 +- scripts/list_predictors.py | 12 +- scripts/list_spaces.py | 2 +- scripts/{list_tasks.py => list_targets.py} | 38 +- src/guard_client/__init__.py | 70 +-- src/guard_client/client.py | 95 +++-- src/guard_client/display.py | 2 +- src/guard_client/exceptions.py | 48 +-- src/guard_client/filters.py | 6 +- src/guard_client/local.py | 41 +- src/guard_client/models.py | 297 +++++++------ .../{activities.py => predictions.py} | 397 +++++++++++------- src/guard_client/predictors.py | 38 +- src/guard_client/reactions.py | 140 +++--- src/guard_client/runners.py | 43 +- src/guard_client/shares.py | 152 +++---- src/guard_client/spaces.py | 47 ++- src/guard_client/{tasks.py => targets.py} | 102 ++--- src/guard_client/tokens.py | 8 +- src/guard_client/transport.py | 4 +- tests/conftest.py | 55 +-- tests/test_analyze.py | 120 ++++-- tests/test_auth.py | 10 +- tests/test_contract.py | 2 +- tests/test_display.py | 26 +- tests/test_estimate.py | 2 +- tests/test_local.py | 29 +- ...test_activities.py => test_predictions.py} | 285 ++++++++----- tests/test_predictors.py | 14 +- tests/test_reactions.py | 108 ++--- tests/test_runners.py | 58 ++- tests/test_shares.py | 123 +++--- tests/test_spaces.py | 37 +- tests/{test_tasks.py => test_targets.py} | 102 ++--- uv.lock | 52 +-- 40 files changed, 1513 insertions(+), 1152 deletions(-) rename scripts/{create_activity.py => create_prediction.py} (85%) rename scripts/{list_tasks.py => list_targets.py} (60%) rename src/guard_client/{activities.py => predictions.py} (60%) rename src/guard_client/{tasks.py => targets.py} (70%) rename tests/{test_activities.py => test_predictions.py} (55%) rename tests/{test_tasks.py => test_targets.py} (52%) diff --git a/.env.example b/.env.example index fb79190..a106571 100644 --- a/.env.example +++ b/.env.example @@ -39,5 +39,5 @@ GUARD_SPACE_ID= #GUARD_ENV_FILE=.env.staging # (OPTIONAL) Default media file to analyze -# Used only by scripts/create_activity.py +# Used only by scripts/create_prediction.py #GUARD_MEDIA=path/to/photo.jpg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa03dd2..deed0f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ The `docs` group is also excluded since it is only used for building documentati To keep the repository organized, please use descriptive branch names based on the type of work you are doing: -* **Features:** `feature/activity-pagination` or `feat/share-expiry-filters` +* **Features:** `feature/prediction-pagination` or `feat/share-expiry-filters` * **Bug Fixes:** `fix/presigned-upload-retry` * **Documentation:** `docs/update-readme` @@ -127,7 +127,7 @@ you are finished, running `uv sync` returns you to the default environment. ### Testing against a Live API The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, create an -activity. **This runs against production by default and spends real tokens** — one activity per run. You can point it +prediction. **This runs against production by default and spends real tokens** — one prediction per run. You can point it elsewhere using the `GUARD_BASE_URL` environment variable. ```bash @@ -135,15 +135,15 @@ export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/ export GUARD_SPACE_ID=... # see the space and the projected cost, creating nothing -uv run python scripts/create_activity.py path/to/photo.jpg --list-only +uv run python scripts/create_prediction.py path/to/photo.jpg --list-only -uv run python scripts/create_activity.py path/to/photo.jpg +uv run python scripts/create_prediction.py path/to/photo.jpg # against a local dev server instead -GUARD_BASE_URL=http://localhost:8000 uv run python scripts/create_activity.py path/to/photo.jpg +GUARD_BASE_URL=http://localhost:8000 uv run python scripts/create_prediction.py path/to/photo.jpg # on-device, which needs no space, no network and no tokens -uv run python scripts/create_activity.py path/to/photo.jpg --engine local +uv run python scripts/create_prediction.py path/to/photo.jpg --engine local ``` Credentials resolve the same way everywhere in this client: explicit arguments, followed by `GUARD_* `environment @@ -157,15 +157,15 @@ If you do not have a `space_id` yet, four more scripts can help: # list the spaces your key can see, with their ids uv run python scripts/list_spaces.py -# the two ids a new space needs: a predictor, and optionally some tasks +# the two ids a new space needs: a predictor, and optionally some targets uv run python scripts/list_predictors.py -uv run python scripts/list_tasks.py --predictor-id +uv run python scripts/list_targets.py --predictor-id -# walk predictors -> tasks -> create a space (--list-only creates nothing) +# walk predictors -> targets -> create a space (--list-only creates nothing) uv run python scripts/create_space.py --list-only # and then, with a space id in hand, analyze something in it -uv run python scripts/create_activity.py path/to/photo.jpg --list-only +uv run python scripts/create_prediction.py path/to/photo.jpg --list-only ``` ## What to Watch Out For @@ -190,8 +190,8 @@ exceptions do not subclass it, so `local.py` translates each one before it escap that does not come from `guard_local` propagates untouched. This is intentional, as a bug in the engine should surface exactly as the bug it is. -**The task labels are a public API.** `_adapt` derives each local result's id from `uuid5(namespace, label)`. Renaming a -label silently changes the ID that callers rely on. These labels also mirror the tasks the cloud API seeds, allowing +**The target labels are a public API.** `_adapt` derives each local result's id from `uuid5(namespace, label)`. Renaming a +label silently changes the ID that callers rely on. These labels also mirror the targets the cloud API seeds, allowing users to test locally and then route to the cloud without changing their code. **Engine scores must be floats between `0.0` and `1.0`.** The `_score_to_int` function rescales by value rather than by diff --git a/README.md b/README.md index 4c9d12b..9df6622 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ and test locally without an API key. Both return the exact same strictly typed m **⚡ Sync & Async Support:** Natively supports both synchronous operations and `async/await` out of the box, making it a perfect fit for high-performance frameworks like FastAPI. -**🛠️ Comprehensive API Bindings:** Typed bindings for the entire API including spaces, activities, tasks, predictors, +**🛠️ Comprehensive API Bindings:** Typed bindings for the entire API including spaces, predictions, targets, predictors, runners, shares, and reactions. We also include token estimation, so you can price a job before you spend on it. ## Installation @@ -51,10 +51,10 @@ pip install "guard-client[local]" ## Quick Start -The `analyze()` method runs the entire detection lifecycle for you. It creates an activity, uploads the media, confirms +The `analyze()` method runs the entire detection lifecycle for you. It creates a prediction, uploads the media, confirms the upload, polls until processing finishes, and returns the result. -You will need an **API key** and a space to create the activity in. You can create a new access token in the **Elhio +You will need an **API key** and a space to create the prediction in. You can create a new access token in the **Elhio dashboard** (under Settings > Account Settings > Access Tokens). If you do not know your space ID yet, you can ask the API for it. The `spaces.list()` method returns every space the key can see. @@ -66,7 +66,7 @@ with GuardClient(api_key="your_api_key_here") as client: # 1. Get an available space space = client.spaces.list()[0] - # 2. Analyze the media (creates activity, uploads, and polls for results) + # 2. Analyze the media (creates prediction, uploads, and polls for results) result = client.analyze("photo.jpg", space_id=space.id) # 3. Print the detection results @@ -74,7 +74,7 @@ with GuardClient(api_key="your_api_key_here") as client: print(f"{item.label}: {item.score}/100") # AI-Generated: 87/100 -# Violence: 2/100 +# Violent: 2/100 # Explicit: 1/100 ``` @@ -110,11 +110,11 @@ with GuardClient(engine="local") as client: print(f"{item.label}: {item.score}/100") # AI-Generated: 90/100 -# Violence: 2/100 +# Violent: 2/100 # Explicit: 1/100 ``` -Two fields easily tell the engines apart. The `result.activity_id` is `None` because a local run creates nothing +Two fields easily tell the engines apart. The `result.prediction_id` is `None` because a local run creates nothing server-side, meaning there is nothing to share or react to. The `result.detected` field carries the engine's own per-category threshold verdict, which the cloud API does not report. Reading it means you are opting into extra detail, not into a different data shape. diff --git a/pyproject.toml b/pyproject.toml index 313f93e..bd34207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "guard-client" -version = "0.0.1" +version = "0.0.2" description = "A Python client for seamlessly integrating visual safety filters into your applications" readme = "README.md" requires-python = ">=3.10" @@ -42,7 +42,7 @@ dependencies = [ ] [project.optional-dependencies] -local = ["guard-local-detector>=0.0.1"] +local = ["guard-local-detector>=0.0.2"] [project.urls] Homepage = "https://elhio.com/" diff --git a/scripts/create_activity.py b/scripts/create_prediction.py similarity index 85% rename from scripts/create_activity.py rename to scripts/create_prediction.py index e064b62..a39fc07 100644 --- a/scripts/create_activity.py +++ b/scripts/create_prediction.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -Create one activity after showing the space and its estimated cost. +Create one prediction after showing the space and its estimated cost. This script runs the complete detection lifecycle through `analyze()`. It creates the -activity, uploads the media, confirms it, polls until completion, and prints the final +prediction, uploads the media, confirms it, polls until completion, and prints the final scores. Be aware that running this against a live API will spend real tokens. You can pass `--list-only` to see the cost estimate without creating anything, or use `--engine local` to run the detection on your device for free. @@ -15,14 +15,14 @@ Example: ```bash # preview the cost without creating anything - uv run python scripts/create_activity.py photo.jpg --list-only + uv run python scripts/create_prediction.py photo.jpg --list-only - # run the activity against the cloud API - uv run python scripts/create_activity.py photo.jpg - uv run python scripts/create_activity.py photo.jpg --space-id + # run the prediction against the cloud API + uv run python scripts/create_prediction.py photo.jpg + uv run python scripts/create_prediction.py photo.jpg --space-id # run on-device instead, which requires no space and consumes no tokens - uv run python scripts/create_activity.py photo.jpg --engine local + uv run python scripts/create_prediction.py photo.jpg --engine local ``` """ @@ -41,8 +41,10 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("media", nargs="?", help="media file; defaults to GUARD_MEDIA") parser.add_argument("--space-id", help="the space to run in; or GUARD_SPACE_ID") - parser.add_argument("--user-id", help="create a user-owned activity") - parser.add_argument("--account-id", help="create a service-account-owned activity") + parser.add_argument("--user-id", help="create a user-owned prediction") + parser.add_argument( + "--account-id", help="create a service-account-owned prediction" + ) parser.add_argument( "--engine", choices=["cloud", "local"], help="client default: cloud" ) @@ -87,13 +89,13 @@ def print_media(path: Path) -> None: def print_space(client: GuardClient, space_id: str, media: Path) -> None: """Show the space this will run in, and what it is expected to cost.""" space = client.spaces.get(space_id) - tasks = ", ".join(task.name for task in space.enabled_tasks) or "-" + targets = ", ".join(target.name for target in space.enabled_targets) or "-" print(f"\nSpace: {space.name}") print(f" id {space.id}") print(f" predictor {space.predictor_name} (x{space.predictor_multiplier})") print(f" max media {space.max_media_size or '-'} bytes") - print(f" tasks {tasks}") + print(f" targets {targets}") print(f"\nEstimated cost: {client.estimate_tokens(media, space_id=space_id)}") print(" An estimate: the API reserves the minimum, payed_tokens is final.") @@ -133,7 +135,7 @@ def main() -> int: ) print(f"\nDone: engine={result.engine.value}") - print(f" activity {result.activity_id or '-'}") + print(f" prediction {result.prediction_id or '-'}") for item in result.results: print(f" - {item.label}: {item.score}/100") if not result.results: diff --git a/scripts/create_space.py b/scripts/create_space.py index fd68f1e..7903a95 100644 --- a/scripts/create_space.py +++ b/scripts/create_space.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """ -Create a space after showing the available predictors and tasks. +Create a space after showing the available predictors and targets. -A space needs a predictor and optionally a set of tasks. This script walks through the -entire flow. It lists the predictors, lists the tasks for the chosen predictor, creates -the space, and prints the new space ID. +A space needs a predictor and optionally a set of targets. This script walks through the +entire flow. It lists the predictors, lists the targets for the chosen predictor, +creates the space, and prints the new space ID. Only GUARD_API_KEY is required. Everything else resolves through the standard client precedence using arguments, the environment, or a `.env` file. @@ -44,14 +44,14 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--public", action="store_true", help="make the space public") parser.add_argument( - "--all-tasks", + "--all-targets", action="store_true", - help="enable every task the predictor supports (default: none)", + help="enable every target the predictor supports (default: none)", ) parser.add_argument( "--list-only", action="store_true", - help="print predictors and tasks, then exit without creating anything", + help="print predictors and targets, then exit without creating anything", ) return parser.parse_args() @@ -74,12 +74,12 @@ def main() -> int: print(f" {predictor.id} {predictor.name:28} media={media}") predictor_id = args.predictor_id or str(predictors[0].id) - tasks = client.tasks.list(predictor_id=predictor_id) + targets = client.targets.list(predictor_id=predictor_id) - print(f"\nTasks for predictor {predictor_id}:") - for task in tasks: - print(f" {task.id} {task.name}") - if not tasks: + print(f"\nTargets for predictor {predictor_id}:") + for target in targets: + print(f" {target.id} {target.name}") + if not targets: print(" (none)") if args.list_only: @@ -90,7 +90,7 @@ def main() -> int: print("\nPass --name to create a space.", file=sys.stderr) return 1 - task_ids = [t.id for t in tasks] if args.all_tasks else None + target_ids = [t.id for t in targets] if args.all_targets else None print(f"\nCreating space {args.name!r}...") space = client.spaces.create( @@ -100,13 +100,13 @@ def main() -> int: is_public=args.public, user_id=args.user_id, organization_id=args.organization_id, - enabled_task_ids=task_ids, + enabled_target_ids=target_ids, ) print(f"\nCreated: {space.id}") print(f" name {space.name}") print(f" public {space.is_public}") - print(f" tasks {', '.join(space.enabled_task_names) or '-'}") + print(f" targets {', '.join(space.enabled_target_names) or '-'}") print(f"\nPut this in .env as GUARD_SPACE_ID={space.id}") except GuardError as exc: print(f"\nFAILED: {type(exc).__name__}: {exc}", file=sys.stderr) diff --git a/scripts/list_predictors.py b/scripts/list_predictors.py index 73fbfa3..79e2f4b 100644 --- a/scripts/list_predictors.py +++ b/scripts/list_predictors.py @@ -7,14 +7,14 @@ required. Everything else resolves through the standard client precedence using arguments, the environment, or a `.env` file. -The `--task-id` argument may be repeated. It narrows the results to predictors -supporting all of the given tasks. Run `scripts/list_tasks.py` to find those IDs. +The `--target-id` argument may be repeated. It narrows the results to predictors +supporting all of the given targets. Run `scripts/list_targets.py` to find those IDs. Example: ```bash uv run python scripts/list_predictors.py uv run python scripts/list_predictors.py --sort-by created_at --sort-order desc - uv run python scripts/list_predictors.py --task-id --task-id + uv run python scripts/list_predictors.py --target-id --target-id ``` """ @@ -35,10 +35,10 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( - "--task-id", + "--target-id", action="append", metavar="UUID", - help="only predictors supporting this task; repeatable", + help="only predictors supporting this target; repeatable", ) parser.add_argument( "--sort-by", choices=["name", "created_at"], help="server default: name" @@ -58,7 +58,7 @@ def main() -> int: filters = { "user_id": args.user_id, "organization_id": args.organization_id, - "supported_task_ids": args.task_id, + "supported_target_ids": args.target_id, "sort_by": args.sort_by, "sort_order": args.sort_order, } diff --git a/scripts/list_spaces.py b/scripts/list_spaces.py index ff7a99e..e87d8dc 100644 --- a/scripts/list_spaces.py +++ b/scripts/list_spaces.py @@ -2,7 +2,7 @@ """ Print the spaces available to your API key along with their IDs. -Creating an activity requires a `space_id`. This script helps you find one to place in +Creating a prediction requires a `space_id`. This script helps you find one to place in your `.env` file as `GUARD_SPACE_ID`. Only the `GUARD_API_KEY` is required. Everything else resolves through the standard client precedence using arguments, the environment, or a `.env` file. diff --git a/scripts/list_tasks.py b/scripts/list_targets.py similarity index 60% rename from scripts/list_tasks.py rename to scripts/list_targets.py index 4f82591..6f11968 100644 --- a/scripts/list_tasks.py +++ b/scripts/list_targets.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -Print the detection tasks available to your API key along with their IDs. +Print the detection targets available to your API key along with their IDs. -A task is one detection a space can run. The `spaces.create` function takes these IDs -as `enabled_task_ids`, making this script useful for finding them. Only the +A target is one detection a space can run. The `spaces.create` function takes these IDs +as `enabled_target_ids`, making this script useful for finding them. Only the `GUARD_API_KEY` is required. Everything else resolves through the standard client precedence using arguments, the environment, or a `.env` file. @@ -13,9 +13,9 @@ Example: ```bash - uv run python scripts/list_tasks.py - uv run python scripts/list_tasks.py --predictor-id - uv run python scripts/list_tasks.py --locale de --sort-by created_at + uv run python scripts/list_targets.py + uv run python scripts/list_targets.py --predictor-id + uv run python scripts/list_targets.py --locale de --sort-by created_at ``` """ @@ -30,12 +30,12 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) owner = parser.add_mutually_exclusive_group() - owner.add_argument("--user-id", help="only tasks available to this user") + owner.add_argument("--user-id", help="only targets available to this user") owner.add_argument( - "--organization-id", help="only tasks available to this organization" + "--organization-id", help="only targets available to this organization" ) - parser.add_argument("--predictor-id", help="only tasks this predictor supports") + parser.add_argument("--predictor-id", help="only targets this predictor supports") parser.add_argument( "--sort-by", choices=["name", "created_at"], help="server default: name" ) @@ -44,7 +44,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--locale", help="language of the labels, e.g. en or de") parser.add_argument( - "--limit", type=int, help="stop after this many tasks (default: all)" + "--limit", type=int, help="stop after this many targets (default: all)" ) return parser.parse_args() @@ -63,28 +63,28 @@ def main() -> int: try: # locale=None keeps the usual precedence: GUARD_LOCALE, then `.env`, then "en" with GuardClient(locale=args.locale) as client: - print(f"Tasks on {client.base_url}\n") + print(f"Targets on {client.base_url}\n") rows = [] - for task in client.tasks.iter_all(**filters): - rows.append(task) + for target in client.targets.iter_all(**filters): + rows.append(target) if args.limit is not None and len(rows) >= args.limit: break if not rows: - print("No tasks matched.") + print("No targets matched.") return 0 width = max(len(t.name) for t in rows) - for task in rows: + for target in rows: reactions = ( - ", ".join(f"{k}: {v}" for k, v in sorted(task.reactions.items())) + ", ".join(f"{k}: {v}" for k, v in sorted(target.reactions.items())) or "-" ) - print(f"{task.id} {task.name:{width}} reactions={{{reactions}}}") + print(f"{target.id} {target.name:{width}} reactions={{{reactions}}}") - print(f"\n{len(rows)} task(s).") - print("Pass the ids above as enabled_task_ids when creating a space.") + print(f"\n{len(rows)} target(s).") + print("Pass the ids above as enabled_target_ids when creating a space.") except GuardError as exc: print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr) return 1 diff --git a/src/guard_client/__init__.py b/src/guard_client/__init__.py index 36d5739..e4f82d6 100644 --- a/src/guard_client/__init__.py +++ b/src/guard_client/__init__.py @@ -15,12 +15,10 @@ from importlib.metadata import PackageNotFoundError, version -from .activities import Activities, AsyncActivities from .client import AsyncGuardClient, GuardClient from .display import BROWSER_RENDERABLE, load_media, save, show from .env import DEFAULT_ENV_FILE, ENV_FILE_VAR, EnvSource, read_env_file from .exceptions import ( - ActivityFailedError, GuardAPIError, GuardAuthError, GuardConflictError, @@ -37,27 +35,28 @@ GuardUploadError, GuardValidationError, LocalEngineNotInstalledError, + PredictionFailedError, UnsupportedMediaTypeError, ) from .filters import MAX_HISTORY, MAX_LIMIT from .media import SUPPORTED_MEDIA_TYPES, MediaSource from .models import ( FILTERABLE_RUNNER_STATUSES, - Activity, - ActivityCreateResponse, - ActivityDetail, - ActivityOrder, - ActivityPage, - ActivityResult, - ActivityResultItem, - ActivityStatus, - ActivityStatusResponse, DetectionMatch, DetectionResult, Engine, MediaCategory, MediaType, Page, + Prediction, + PredictionCreateResponse, + PredictionDetail, + PredictionOrder, + PredictionPage, + PredictionResult, + PredictionResultItem, + PredictionStatus, + PredictionStatusResponse, Predictor, PredictorOrder, PredictorPage, @@ -65,6 +64,7 @@ PresignedUploadData, Reaction, Runner, + RunnerDeployment, RunnerOrder, RunnerPage, RunnerStatus, @@ -79,18 +79,19 @@ SpacePage, SpaceStatus, SpaceThresholds, - Task, - TaskOrder, - TaskPage, - TaskStatus, + Target, + TargetOrder, + TargetPage, + TargetStatus, ) +from .predictions import AsyncPredictions, Predictions from .predictors import AsyncPredictors, Predictors from .probe import MediaInfo, probe_media from .reactions import AsyncReactions, Reactions from .runners import AsyncRunners, Runners from .shares import AsyncShares, Shares from .spaces import AsyncSpaces, Spaces -from .tasks import AsyncTasks, Tasks +from .targets import AsyncTargets, Targets from .tokens import ( MAX_LONG_SIDE, RESOLUTION_TIERS, @@ -119,14 +120,14 @@ "SUPPORTED_MEDIA_TYPES", "GuardClient", "AsyncGuardClient", - "Activities", - "AsyncActivities", + "Predictions", + "AsyncPredictions", "Spaces", "AsyncSpaces", "Predictors", "AsyncPredictors", - "Tasks", - "AsyncTasks", + "Targets", + "AsyncTargets", "Runners", "AsyncRunners", "Reactions", @@ -142,15 +143,15 @@ "frames_for", "probe_media", "tier_for", - "Activity", - "ActivityCreateResponse", - "ActivityDetail", - "ActivityOrder", - "ActivityPage", - "ActivityResult", - "ActivityResultItem", - "ActivityStatus", - "ActivityStatusResponse", + "Prediction", + "PredictionCreateResponse", + "PredictionDetail", + "PredictionOrder", + "PredictionPage", + "PredictionResult", + "PredictionResultItem", + "PredictionStatus", + "PredictionStatusResponse", "DetectionMatch", "DetectionResult", "Engine", @@ -165,6 +166,7 @@ "PresignedUploadData", "Reaction", "Runner", + "RunnerDeployment", "RunnerOrder", "RunnerPage", "RunnerStatus", @@ -180,12 +182,12 @@ "SpacePage", "SpaceStatus", "SpaceThresholds", - "Task", + "Target", "TokenEstimate", - "TaskOrder", - "TaskPage", - "TaskStatus", - "ActivityFailedError", + "TargetOrder", + "TargetPage", + "TargetStatus", + "PredictionFailedError", "GuardAPIError", "GuardAuthError", "GuardConflictError", diff --git a/src/guard_client/client.py b/src/guard_client/client.py index 04ec37f..727ab27 100644 --- a/src/guard_client/client.py +++ b/src/guard_client/client.py @@ -12,25 +12,25 @@ import httpx -from .activities import ( - DEFAULT_POLL_INTERVAL, - DEFAULT_POLL_TIMEOUT, - Activities, - AsyncActivities, -) from .env import DEFAULT_ENV_FILE, EnvFile, EnvSource from .exceptions import GuardError from .filters import IdLike from .local import LocalRunner from .media import MediaSource, resolve_media from .models import DetectionResult, Engine, MediaType, coerce_enum +from .predictions import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_POLL_TIMEOUT, + AsyncPredictions, + Predictions, +) from .predictors import AsyncPredictors, Predictors from .probe import probe_media from .reactions import AsyncReactions, Reactions from .runners import AsyncRunners, Runners from .shares import AsyncShares, Shares from .spaces import AsyncSpaces, Spaces -from .tasks import AsyncTasks, Tasks +from .targets import AsyncTargets, Targets from .tokens import TokenEstimate, estimate_tokens, frames_for from .transport import DEFAULT_BASE_URL, AsyncTransport, SyncTransport, TransportConfig @@ -192,6 +192,29 @@ def _effective_engine(self, engine: Optional[EngineLike]) -> Engine: """ return self._engine if engine is None else _coerce_engine(engine) + @staticmethod + def _declared_dimensions( + data: bytes, media_type: MediaType, filename: Optional[str] + ) -> Dict[str, Any]: + """ + Probe the media for the dimensions a prediction declares on creation. + + The API reserves tokens from them. Media the probe cannot read is still + analyzed, just without them. + + Returns: + `width`, `height` and, for a video, `duration_seconds`, or nothing when the + probe failed. + """ + try: + info = probe_media(data, media_type=media_type, filename=filename) + except GuardError: + return {} + dimensions: Dict[str, Any] = {"width": info.width, "height": info.height} + if info.media_type.value.startswith("video/"): + dimensions["duration_seconds"] = info.duration_seconds + return dimensions + @staticmethod def _resolve_estimate_inputs( *, @@ -336,7 +359,7 @@ def __init__( self._transport = SyncTransport(self._config, http_client=http_client) # The resolved ids, not the raw arguments. They may have come from the # environment. - self.activities = Activities(self._transport, default_space_id=self._space_id) + self.predictions = Predictions(self._transport, default_space_id=self._space_id) self.spaces = Spaces( self._transport, default_organization_id=self._organization_id ) @@ -344,7 +367,7 @@ def __init__( self._transport, default_organization_id=self._organization_id ) self.predictors = Predictors(self._transport) - self.tasks = Tasks(self._transport) + self.targets = Targets(self._transport) self.reactions = Reactions(self._transport) self.shares = Shares(self._transport) @@ -364,7 +387,7 @@ def analyze( """ Run the full detection lifecycle for one piece of media. - In cloud mode this creates an activity, uploads the bytes, confirms the upload, + In cloud mode this creates a prediction, uploads the bytes, confirms the upload, polls until processing finishes, and returns the result. In local mode it runs on-device and makes no network calls. @@ -374,7 +397,7 @@ def analyze( engine: Overrides the client-level default engine for this call. media_type: Skips MIME detection when you already know the type. filename: Used for detection and for naming the uploaded file. - user_id: Owning user, for a user-owned activity. + user_id: Owning user, for a user-owned prediction. account_id: Owning service account. poll_interval: Seconds between status polls. timeout: Seconds to wait for processing before giving up. @@ -383,7 +406,7 @@ def analyze( A `DetectionResult`, identical in shape for either engine. Raises: - ActivityFailedError: Processing ended as `failed` or `canceled`. + PredictionFailedError: Processing ended as `failed` or `canceled`. GuardTimeoutError: Processing did not finish within `timeout`. UnsupportedMediaTypeError: The media type is not accepted. LocalEngineNotInstalledError: Local was requested without the extra. @@ -405,25 +428,26 @@ def analyze( if self._effective_engine(engine) is Engine.LOCAL: return self._local.analyze(data, media_type=resolved_type, filename=name) - activity = self.activities.create( + prediction = self.predictions.create( media_type=resolved_type, media_size=len(data), space_id=space_id, user_id=user_id, account_id=account_id, + **self._declared_dimensions(data, resolved_type, name), ) - self.activities.upload( - activity.upload_data, data, media_type=resolved_type, filename=name + self.predictions.upload( + prediction.upload_data, data, media_type=resolved_type, filename=name ) - self.activities.confirm(activity.id) - self.activities.wait_until_done( - activity.id, interval=poll_interval, timeout=timeout + self.predictions.confirm(prediction.id) + self.predictions.wait_until_done( + prediction.id, interval=poll_interval, timeout=timeout ) - detail = self.activities.get(activity.id) + detail = self.predictions.get(prediction.id) results = detail.result_payload.results if detail.result_payload else [] return DetectionResult( - engine=Engine.CLOUD, results=results, activity_id=activity.id + engine=Engine.CLOUD, results=results, prediction_id=prediction.id ) def estimate_tokens( @@ -440,7 +464,7 @@ def estimate_tokens( filename: Optional[str] = None, ) -> TokenEstimate: """ - Project what analyzing this media will cost before creating an activity. + Project what analyzing this media will cost before creating a prediction. `tokens = frames x resolution_cost x multiplier`. Anything not given is read from `source`. Anything given overrides the probe, so you can correct a single @@ -467,8 +491,8 @@ def estimate_tokens( Note: This is an estimate. The API currently reserves only the minimum possible - cost when an activity is created, and `payed_tokens` on the finished - activity is authoritative. Expect the two to differ. + cost when a prediction is created, and `payed_tokens` on the finished + prediction is authoritative. Expect the two to differ. """ resolved = self._resolve_estimate_inputs( source=source, @@ -616,7 +640,7 @@ def __init__( self._transport = AsyncTransport(self._config, http_client=http_client) # The resolved ids, not the raw arguments: they may have come from the # environment. - self.activities = AsyncActivities( + self.predictions = AsyncPredictions( self._transport, default_space_id=self._space_id ) self.spaces = AsyncSpaces( @@ -626,7 +650,7 @@ def __init__( self._transport, default_organization_id=self._organization_id ) self.predictors = AsyncPredictors(self._transport) - self.tasks = AsyncTasks(self._transport) + self.targets = AsyncTargets(self._transport) self.reactions = AsyncReactions(self._transport) self.shares = AsyncShares(self._transport) @@ -659,25 +683,26 @@ async def analyze( data, media_type=resolved_type, filename=name ) - activity = await self.activities.create( + prediction = await self.predictions.create( media_type=resolved_type, media_size=len(data), space_id=space_id, user_id=user_id, account_id=account_id, + **self._declared_dimensions(data, resolved_type, name), ) - await self.activities.upload( - activity.upload_data, data, media_type=resolved_type, filename=name + await self.predictions.upload( + prediction.upload_data, data, media_type=resolved_type, filename=name ) - await self.activities.confirm(activity.id) - await self.activities.wait_until_done( - activity.id, interval=poll_interval, timeout=timeout + await self.predictions.confirm(prediction.id) + await self.predictions.wait_until_done( + prediction.id, interval=poll_interval, timeout=timeout ) - detail = await self.activities.get(activity.id) + detail = await self.predictions.get(prediction.id) results = detail.result_payload.results if detail.result_payload else [] return DetectionResult( - engine=Engine.CLOUD, results=results, activity_id=activity.id + engine=Engine.CLOUD, results=results, prediction_id=prediction.id ) async def estimate_tokens( @@ -694,13 +719,13 @@ async def estimate_tokens( filename: Optional[str] = None, ) -> TokenEstimate: """ - Project what analyzing this media will cost before creating an activity. + Project what analyzing this media will cost before creating a prediction. Returns: A `TokenEstimate`. See `GuardClient.estimate_tokens` for every argument. Warning: - This is an estimate, not a quote. `payed_tokens` on the finished activity + This is an estimate, not a quote. `payed_tokens` on the finished prediction is authoritative. """ resolved = self._resolve_estimate_inputs( diff --git a/src/guard_client/display.py b/src/guard_client/display.py index 36a5782..b5541eb 100644 --- a/src/guard_client/display.py +++ b/src/guard_client/display.py @@ -117,7 +117,7 @@ def load_media( Args: source: A path, raw `bytes`, an open binary file, an HTTP or HTTPS URL, - or an object with a `media_url` such as an `ActivityResultItem` or `Share`. + or an object with a `media_url` such as a `PredictionResultItem` or `Share`. media_type: Skips detection when you already know the type. filename: Used for detection and for naming a saved file. diff --git a/src/guard_client/exceptions.py b/src/guard_client/exceptions.py index e4e0927..337120d 100644 --- a/src/guard_client/exceptions.py +++ b/src/guard_client/exceptions.py @@ -21,7 +21,7 @@ Examples: ```python from guard_client import ( - ActivityFailedError, + PredictionFailedError, GuardAuthError, GuardError, GuardTimeoutError, @@ -31,7 +31,7 @@ result = client.analyze("photo.jpg") except GuardAuthError: pass # Handle bad or expired API key - except ActivityFailedError as exc: + except PredictionFailedError as exc: pass # Processing ended "failed" or "canceled". exc.status says which. except GuardTimeoutError: pass # Still processing when the deadline passed @@ -51,7 +51,7 @@ from uuid import UUID __all__ = [ - "ActivityFailedError", + "PredictionFailedError", "GuardAPIError", "GuardAuthError", "GuardConflictError", @@ -152,7 +152,7 @@ class GuardNotFoundError(GuardAPIError): Note: Several routes answer 404 for resources that exist but do not belong to you. This means a 404 does not strictly prove absence. Reacting to someone else's - activity reports the same error as reacting to an id that was never issued. + prediction reports the same error as reacting to an id that was never issued. """ @@ -194,8 +194,8 @@ class GuardConflictError(GuardAPIError): The resource clashes with one that already exists (409). This is raised when a space or runner name is already taken in the same context, - when an activity result already has a reaction, or when an activity has already been - shared. + when a prediction result already has a reaction, or when a prediction has already + been shared. Note: This error is often benign. Because the client never retries a create operation, @@ -283,59 +283,59 @@ def __init__(self, message: str, *, status_code: Optional[int] = None) -> None: class GuardTimeoutError(GuardError): """ - An activity did not reach a terminal status before the polling deadline. + A prediction did not reach a terminal status before the polling deadline. - The activity is still running server-side. Only the waiting process stopped. You + The prediction is still running server-side. Only the waiting process stopped. You should fetch it again later rather than resubmitting the media. Args: message: What timed out, including the last status seen. - activity_id: The activity that was being polled. + prediction_id: The prediction that was being polled. Attributes: - activity_id: The activity still in flight so it can be polled again. + prediction_id: The prediction still in flight so it can be polled again. """ - def __init__(self, message: str, *, activity_id: Optional[UUID] = None) -> None: + def __init__(self, message: str, *, prediction_id: Optional[UUID] = None) -> None: """ - Store which activity was still running when the deadline passed. + Store which prediction was still running when the deadline passed. Args: message: What timed out, including the last status seen. - activity_id: The activity that was being polled. + prediction_id: The prediction that was being polled. """ super().__init__(message) - self.activity_id = activity_id + self.prediction_id = prediction_id -class ActivityFailedError(GuardError): +class PredictionFailedError(GuardError): """ - An activity reached a terminal status other than `completed`. + A prediction reached a terminal status other than `completed`. Args: - message: What happened, naming the activity and status. + message: What happened, naming the prediction and status. status: The terminal status reached, either `failed` or `canceled`. - activity_id: The activity that ended. + prediction_id: The prediction that ended. Attributes: status: The terminal status reached. - activity_id: The activity that ended. + prediction_id: The prediction that ended. """ def __init__( - self, message: str, *, status: str, activity_id: Optional[UUID] = None + self, message: str, *, status: str, prediction_id: Optional[UUID] = None ) -> None: """ - Store which activity ended and how. + Store which prediction ended and how. Args: - message: What happened, naming the activity and status. + message: What happened, naming the prediction and status. status: The terminal status reached. - activity_id: The activity that ended. + prediction_id: The prediction that ended. """ super().__init__(message) self.status = status - self.activity_id = activity_id + self.prediction_id = prediction_id class UnsupportedMediaTypeError(GuardError, ValueError): diff --git a/src/guard_client/filters.py b/src/guard_client/filters.py index 9dbc044..94fbaf5 100644 --- a/src/guard_client/filters.py +++ b/src/guard_client/filters.py @@ -37,8 +37,8 @@ #: The server caps a single page at this many items. MAX_LIMIT = 100 -#: How far back activity history reaches. This matches `one_year_ago` in the -#: `read_activities` endpoint of the API. +#: How far back prediction history reaches. This matches `one_year_ago` in the +#: `read_predictions` endpoint of the API. MAX_HISTORY = timedelta(days=365) #: Tolerance applied when checking `MAX_HISTORY` locally. The server computes its cutoff @@ -275,7 +275,7 @@ def add_sort( Add `sort_by` and `sort_order` when given. Leaving them out lets the server apply its own default. The default differs per - resource. Spaces sort ascending, while activities and shares sort descending. + resource. Spaces sort ascending, while predictions and shares sort descending. Args: params: The query dict to add to. This is mutated in place. diff --git a/src/guard_client/local.py b/src/guard_client/local.py index c88d9ea..4363a59 100644 --- a/src/guard_client/local.py +++ b/src/guard_client/local.py @@ -29,11 +29,11 @@ ) from .media import MediaSource, resolve_media from .models import ( - ActivityResultItem, DetectionMatch, DetectionResult, Engine, MediaType, + PredictionResultItem, ) __all__ = ["LocalEngine", "LocalRunner"] @@ -44,10 +44,10 @@ ' pip install "guard-client[local]"' ) -#: A fixed UUID namespace used to derive stable task IDs for local runs. Local runs have -#: no server-assigned task IDs, so we derive them from the label. A fixed namespace -#: keeps them reproducible across processes and releases. -_LOCAL_TASK_NAMESPACE = UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff") +#: A fixed UUID namespace used to derive stable target IDs for local runs. Local runs +#: have no server-assigned target IDs, so we derive them from the label. A fixed +#: namespace keeps them reproducible across processes and releases. +_LOCAL_TARGET_NAMESPACE = UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff") #: Maps the exception name from `guard_local.exceptions` to the `GuardError` raised in #: its place. The engine's hierarchy derives from nothing here, so without this map an @@ -248,7 +248,7 @@ def _matches(value: Any) -> Optional[List[DetectionMatch]]: return matches -def _adapt(raw: Any) -> List[ActivityResultItem]: +def _adapt(raw: Any) -> List[PredictionResultItem]: """ Turn raw engine output into the items the cloud API returns. @@ -261,31 +261,32 @@ def _adapt(raw: Any) -> List[ActivityResultItem]: raw: The raw output returned by the engine. Returns: - A list of standardized `ActivityResultItem` objects. + A list of standardized `PredictionResultItem` objects. """ if raw is None: return [] entries = raw if isinstance(raw, (list, tuple)) else [raw] - items: List[ActivityResultItem] = [] + items: List[PredictionResultItem] = [] for entry in entries: if not isinstance(entry, dict): continue label = str(entry.get("label") or entry.get("status") or "unknown") - raw_task_id = entry.get("task_id") + # the engine may still name it `task_id`, the API's name before the rename + raw_target_id = entry.get("target_id") or entry.get("task_id") try: - task_id = ( - UUID(str(raw_task_id)) - if raw_task_id - else uuid5(_LOCAL_TASK_NAMESPACE, label) + target_id = ( + UUID(str(raw_target_id)) + if raw_target_id + else uuid5(_LOCAL_TARGET_NAMESPACE, label) ) except ValueError: - task_id = uuid5(_LOCAL_TASK_NAMESPACE, label) + target_id = uuid5(_LOCAL_TARGET_NAMESPACE, label) detected = entry.get("detected") items.append( - ActivityResultItem( - task_id=task_id, + PredictionResultItem( + target_id=target_id, score=_score_to_int(entry.get("score")), label=label, description=entry.get("description"), @@ -353,8 +354,8 @@ def analyze( filename: Used for detection and error messages. Returns: - A `DetectionResult` with `engine=LOCAL` and no `activity_id` because nothing - was created server-side. + A `DetectionResult` with `engine=LOCAL` and no `prediction_id` because + nothing was created server-side. Raises: LocalEngineNotInstalledError: The optional engine is unavailable. @@ -371,7 +372,7 @@ def analyze( engine = self._ensure_engine() raw = engine.analyze(data, resolved_type.value) return DetectionResult( - engine=Engine.LOCAL, results=_adapt(raw), activity_id=None + engine=Engine.LOCAL, results=_adapt(raw), prediction_id=None ) async def analyze_async( @@ -418,5 +419,5 @@ async def analyze_async( raw = await asyncio.to_thread(engine.analyze, data, resolved_type.value) return DetectionResult( - engine=Engine.LOCAL, results=_adapt(raw), activity_id=None + engine=Engine.LOCAL, results=_adapt(raw), prediction_id=None ) diff --git a/src/guard_client/models.py b/src/guard_client/models.py index 8b2931c..ad2e1ac 100644 --- a/src/guard_client/models.py +++ b/src/guard_client/models.py @@ -17,22 +17,22 @@ from .exceptions import GuardError __all__ = [ - "FILTERABLE_RUNNER_STATUSES", - "Activity", - "ActivityCreateResponse", - "ActivityDetail", - "ActivityOrder", - "ActivityPage", - "ActivityResult", - "ActivityResultItem", - "ActivityStatus", - "ActivityStatusResponse", "DetectionMatch", "DetectionResult", "Engine", + "FILTERABLE_RUNNER_STATUSES", "MediaCategory", "MediaType", "Page", + "Prediction", + "PredictionCreateResponse", + "PredictionDetail", + "PredictionOrder", + "PredictionPage", + "PredictionResult", + "PredictionResultItem", + "PredictionStatus", + "PredictionStatusResponse", "Predictor", "PredictorOrder", "PredictorPage", @@ -41,6 +41,7 @@ "Reaction", "ResultSource", "Runner", + "RunnerDeployment", "RunnerOrder", "RunnerPage", "RunnerStatus", @@ -55,13 +56,13 @@ "SpacePage", "SpaceStatus", "SpaceThresholds", - "Task", - "TaskOrder", - "TaskPage", - "TaskStatus", - "activity_id_of", + "Target", + "TargetOrder", + "TargetPage", + "TargetStatus", "coerce_enum", "ensure_bool", + "prediction_id_of", "result_items_of", ] @@ -77,11 +78,11 @@ class _Base(BaseModel): model_config = ConfigDict(extra="ignore") -class ActivityStatus(str, Enum): +class PredictionStatus(str, Enum): """ - Lifecycle status of an activity. + Lifecycle status of a prediction. - An activity advances from `PENDING_UPLOAD` through `PROCESSING` to one of three + A prediction advances from `PENDING_UPLOAD` through `PROCESSING` to one of three terminal states. Only `COMPLETED` carries results. Attributes: @@ -101,7 +102,7 @@ class ActivityStatus(str, Enum): @property def is_terminal(self) -> bool: """ - Whether the activity has stopped moving. + Whether the prediction has stopped moving. Returns: `True` for `COMPLETED`, `FAILED`, and `CANCELED`. Polling stops on these @@ -110,9 +111,9 @@ def is_terminal(self) -> bool: return self in _TERMINAL_STATUSES -#: A set containing the terminal activity statuses. +#: A set containing the terminal prediction statuses. _TERMINAL_STATUSES = frozenset( - {ActivityStatus.COMPLETED, ActivityStatus.FAILED, ActivityStatus.CANCELED} + {PredictionStatus.COMPLETED, PredictionStatus.FAILED, PredictionStatus.CANCELED} ) @@ -148,9 +149,9 @@ class Engine(str, Enum): Which detection engine produced a result. Attributes: - CLOUD: The Elhio API. Creates an activity so the result has an `activity_id`. + CLOUD: The Elhio API. Creates a prediction so the result has an `prediction_id`. LOCAL: The optional on-device engine. Creates nothing server-side so - the result has no `activity_id` and cannot be shared or reacted to. + the result has no `prediction_id` and cannot be shared or reacted to. """ CLOUD = "cloud" @@ -162,7 +163,7 @@ class SortOrder(str, Enum): Direction for a `sort_by` field. Shared by every list endpoint, though their defaults differ. Spaces sort ascending, - while activities and shares sort descending. + while predictions and shares sort descending. Attributes: ASC: Ascending. @@ -175,7 +176,7 @@ class SortOrder(str, Enum): class SpaceOrder(str, Enum): """ - Fields activities can be sorted by. + Fields predictions can be sorted by. Attributes: NAME: Alphabetical by space name. @@ -186,9 +187,9 @@ class SpaceOrder(str, Enum): CREATED_AT = "created_at" -class ActivityOrder(str, Enum): +class PredictionOrder(str, Enum): """ - Fields activities can be sorted by. + Fields predictions can be sorted by. Attributes: CREATED_AT: By creation time. This is the only option and the server default. @@ -210,12 +211,12 @@ class PredictorOrder(str, Enum): CREATED_AT = "created_at" -class TaskOrder(str, Enum): +class TargetOrder(str, Enum): """ - Fields tasks can be sorted by. + Fields targets can be sorted by. Attributes: - NAME: Alphabetical by task name. This is the server default. + NAME: Alphabetical by target name. This is the server default. CREATED_AT: By creation time. """ @@ -245,9 +246,9 @@ class PredictorStatus(str, Enum): ACTIVE = "active" -class TaskStatus(str, Enum): +class TargetStatus(str, Enum): """ - Lifecycle status of a task as exposed publicly. + Lifecycle status of a target as exposed publicly. Attributes: ACTIVE: Available for use. @@ -304,9 +305,23 @@ class RunnerOrder(str, Enum): CREATED_AT = "created_at" +class RunnerDeployment(str, Enum): + """ + Where a runner runs. + + Attributes: + MANAGED: Elhio deploys and operates it. This is the server default. + EXTERNAL: You run it on your own hardware and enroll it with a token. Only + plans that allow external runners can create one. + """ + + MANAGED = "managed" + EXTERNAL = "external" + + class ShareStatus(str, Enum): """ - Lifecycle status of an activity share. + Lifecycle status of a prediction share. Filter-only. The API accepts these on `statuses` but returns no status field, so a fetched `Share` reports expiry through `Share.is_expired` instead. @@ -322,7 +337,7 @@ class ShareStatus(str, Enum): class ShareOrder(str, Enum): """ - Fields activity shares can be sorted by. + Fields prediction shares can be sorted by. Attributes: CREATED_AT: By creation time. This is the only option and the server default. @@ -409,7 +424,7 @@ def ensure_bool(value: object, *, field: str) -> bool: class PresignedUploadData(_Base): """ - S3 presigned POST descriptor returned when an activity is created. + S3 presigned POST descriptor returned when a prediction is created. Attributes: url: Where to POST the media. This is not the API host and is not authenticated @@ -456,22 +471,22 @@ class DetectionMatch(_Base): source: Optional[str] = None -class ActivityResultItem(_Base): +class PredictionResultItem(_Base): """ - One task verdict on a piece of media. + One target verdict on a piece of media. - An activity produces one of these per enabled task. The `label` and `description` + A prediction produces one of these per enabled target. The `label` and `description` arrive already resolved to the client locale. Attributes: - task_id: Which detection produced this. Match it against `Task` ids. + target_id: Which detection produced this. Match it against `Target` ids. score: 0-100 where higher means more strongly detected. This is not a percentage probability. label: Human-readable name of the detection in the request locale. description: Longer explanation in the request locale when one exists. media_key: Storage key for the solution image. This is internal. `media_url` is the fetchable form. - media_url: Public URL of the solution image, or `None` when this task produced + media_url: Public URL of the solution image, or `None` when this target produced no image. It is unauthenticated so `show` and `save` need no credentials. detected: The engine's threshold verdict, which knows a per-category cutoff no caller could infer from `score` alone. This is `None` from the cloud API @@ -486,7 +501,7 @@ class ActivityResultItem(_Base): automatically if the API starts returning them. """ - task_id: UUID + target_id: UUID score: int = Field(ge=0, le=100, description="Score between 0-100") label: str description: Optional[str] = None @@ -496,32 +511,32 @@ class ActivityResultItem(_Base): matches: Optional[List[DetectionMatch]] = None -class ActivityResult(_Base): +class PredictionResult(_Base): """ - The result payload of a completed activity. + The result payload of a completed prediction. Attributes: - results: One entry per task the space had enabled. This is empty until + results: One entry per target the space had enabled. This is empty until processing finishes. """ - results: List[ActivityResultItem] = Field(default_factory=list) + results: List[PredictionResultItem] = Field(default_factory=list) -class _ActivityOwner(_Base): +class _PredictionOwner(_Base): """ - The mutually-exclusive owner identifiers carried by most activity models. + The mutually-exclusive owner identifiers carried by most prediction models. - Exactly one is set to identify who the activity belongs to. This determines who may - react to or share it. + Exactly one is set to identify who the prediction belongs to. This determines who + may react to or share it. Attributes: user_id: Owning user when a user token created it. account_id: Owning service account when a service account token created it. guest_id: Owning guest for anonymous flows in a public space. This is read-only here. This client always authenticates and the API assigns - a guest owner only to activities created without an identity, such - as by the browser extension. It is reported so those activities still + a guest owner only to predictions created without an identity, such + as by the browser extension. It is reported so those predictions still round-trip, but is never set by us. """ @@ -530,39 +545,43 @@ class _ActivityOwner(_Base): guest_id: Optional[UUID] = None -class Activity(_ActivityOwner): +class Prediction(_PredictionOwner): """ An analysis run as it appears in listings. - This is the summary shape. `ActivityDetail` adds the results and processing + This is the summary shape. `PredictionDetail` adds the results and processing metadata. Attributes: id: Server-assigned identifier. status: Where the run has got to. created_at: When it was created. - space_id: The space it belongs to. + space_id: The space it belongs to, or `None` for a lab's prediction. + lab_id: The lab it belongs to, or `None` for a space's prediction. space_name: That space display name. + lab_name: That lab display name. user_name: Owning user display name when a user owns it. account_name: Owning service account name when one owns it. media_type: MIME type of the submitted media. """ id: UUID - status: ActivityStatus + status: PredictionStatus created_at: datetime - space_id: UUID + space_id: Optional[UUID] = None + lab_id: Optional[UUID] = None space_name: Optional[str] = None + lab_name: Optional[str] = None user_name: Optional[str] = None account_name: Optional[str] = None media_type: MediaType -class ActivityCreateResponse(Activity): +class PredictionCreateResponse(Prediction): """ - An activity plus the one-time target for uploading its media. + A prediction plus the one-time target for uploading its media. - This is returned only from creation. Fetching the activity again will not give you + This is returned only from creation. Fetching the prediction again will not give you `upload_data` a second time, so upload before discarding it. Attributes: @@ -572,34 +591,35 @@ class ActivityCreateResponse(Activity): upload_data: PresignedUploadData -class ActivityStatusResponse(_ActivityOwner): +class PredictionStatusResponse(_PredictionOwner): """ - Just enough of an activity to poll it. + Just enough of a prediction to poll it. This is deliberately small. Polling fetches this rather than the full detail so waiting does not repeatedly transfer its results. Attributes: - id: The activity being polled. + id: The prediction being polled. status: Where the run has got to. """ id: UUID - status: ActivityStatus + status: PredictionStatus -class ActivityDetail(_ActivityOwner): +class PredictionDetail(_PredictionOwner): """ - An activity with everything the API knows about it. + A prediction with everything the API knows about it. - This is what `activities.get()` returns and the shape carrying the results. + This is what `predictions.get()` returns and the shape carrying the results. Attributes: id: Server-assigned identifier. status: Where the run has got to. created_at: When it was created. updated_at: When it last changed status. - space_id: The space it belongs to. + space_id: The space it belongs to, or `None` for a lab's prediction. + lab_id: The lab it belongs to, or `None` for a space's prediction. predictor_id: The model that processed it. runner_id: The runner that served it when a dedicated one did. media_type: MIME type of the submitted media. @@ -609,6 +629,7 @@ class ActivityDetail(_ActivityOwner): processing ends. result_payload: The scored results, or `None` while still processing. space_name: The space display name. + lab_name: The lab display name. predictor_name: The predictor display name. runner_name: The runner name when a dedicated one served it. user_name: Owning user display name. @@ -616,17 +637,19 @@ class ActivityDetail(_ActivityOwner): """ id: UUID - status: ActivityStatus + status: PredictionStatus created_at: datetime updated_at: datetime space_id: Optional[UUID] = None + lab_id: Optional[UUID] = None predictor_id: Optional[UUID] = None runner_id: Optional[UUID] = None media_type: MediaType media_size: int payed_tokens: Optional[int] = None - result_payload: Optional[ActivityResult] = None + result_payload: Optional[PredictionResult] = None space_name: Optional[str] = None + lab_name: Optional[str] = None predictor_name: Optional[str] = None runner_name: Optional[str] = None user_name: Optional[str] = None @@ -635,9 +658,9 @@ class ActivityDetail(_ActivityOwner): class Space(_Base): """ - A container that activities are created in as it appears in listings. + A container that predictions are created in as it appears in listings. - A space fixes which predictor runs, which tasks are enabled, and who owns the + A space fixes which predictor runs, which targets are enabled, and who owns the results. `SpaceDetail` is the fuller shape from `spaces.get()`. Attributes: @@ -657,8 +680,8 @@ class Space(_Base): predictor_id: The model this space runs. predictor_name: That predictor display name. enabled_media: Which media categories may be submitted. - enabled_task_names: Names of the enabled detections. The detail endpoint - returns full task objects under `enabled_tasks` instead. + enabled_target_names: Names of the enabled detections. The detail endpoint + returns full target objects under `enabled_targets` instead. """ id: UUID @@ -677,7 +700,7 @@ class Space(_Base): predictor_id: UUID predictor_name: Optional[str] = None enabled_media: List[MediaCategory] = Field(default_factory=list) - enabled_task_names: List[str] = Field(default_factory=list) + enabled_target_names: List[str] = Field(default_factory=list) @property def owner_name(self) -> Optional[str]: @@ -703,11 +726,11 @@ class Predictor(_Base): name: Display name. status: Lifecycle status. Only `ACTIVE` is ever returned. description: What the model does. - token_multiplier: Cost multiplier applied to every activity in a space using it. + token_multiplier: Cost multiplier for every prediction in a space using it. slug: URL-safe form of the name. url_id: Short public identifier used in web links. supported_media: Which media categories it can process. - supported_task_ids: Which detections it can run. + supported_target_ids: Which detections it can run. """ id: UUID @@ -718,15 +741,15 @@ class Predictor(_Base): slug: str url_id: str supported_media: List[MediaCategory] = Field(default_factory=list) - supported_task_ids: List[UUID] = Field(default_factory=list) + supported_target_ids: List[UUID] = Field(default_factory=list) -class Task(_Base): +class Target(_Base): """ One detection a space can enable, such as deepfake or violence. Attributes: - id: Server-assigned identifier used for `spaces.create(enabled_task_ids=...)`. + id: Server-assigned identifier used for `spaces.create(enabled_target_ids=...)`. status: Lifecycle status. Only `ACTIVE` is ever returned. name: Display name in the request locale. description: What the detection looks for in the request locale. @@ -735,7 +758,7 @@ class Task(_Base): """ id: UUID - status: TaskStatus + status: TargetStatus name: str description: Optional[str] = None reactions: Dict[int, str] = Field( @@ -749,45 +772,45 @@ class Task(_Base): class Reaction(_Base): """ - Feedback on whether one task result was correct. + Feedback on whether one target result was correct. - The API accepts one reaction per activity and task, and offers no way for a + The API accepts one reaction per prediction and target, and offers no way for a non-admin to read or remove one afterwards. This is only ever seen as the return value of creating it. Attributes: id: Server-assigned identifier. created_at: When the feedback was recorded. - activity_id: The activity being commented on. - task_id: Which task result the feedback concerns. + prediction_id: The prediction being commented on. + target_id: Which target result the feedback concerns. is_positive: `True` if the detection was right. - key_value: The expected result as a key of that task `reactions` map. + key_value: The expected result as a key of that target `reactions` map. description: Free-text comment up to 255 characters. """ id: UUID created_at: datetime - activity_id: UUID - task_id: UUID + prediction_id: UUID + target_id: UUID is_positive: bool key_value: Optional[int] = Field( default=None, - description="The expected result, keyed into the task's `reactions` map", + description="The expected result, keyed into the target's `reactions` map", ) description: Optional[str] = None class SpaceThresholds(_Base): """ - Per-task score cut-offs configured on a space. + Per-target score cut-offs configured on a space. Attributes: - task_id: Which detection these thresholds apply to. + target_id: Which detection these thresholds apply to. blur_threshold: Score at or above which media is blurred, 0-100. hide_threshold: Score at or above which media is hidden entirely, 0-100. """ - task_id: UUID + target_id: UUID blur_threshold: Optional[int] = Field(default=None, ge=0, le=100) hide_threshold: Optional[int] = Field(default=None, ge=0, le=100) @@ -797,8 +820,8 @@ class SpaceDetail(_Base): A space with its full configuration. This is deliberately not a subclass of `Space`. The detail endpoint returns - `enabled_tasks` as full task objects, whereas the list endpoint returns - `enabled_task_names` as plain strings. The two shapes genuinely diverge. + `enabled_targets` as full target objects, whereas the list endpoint returns + `enabled_target_names` as plain strings. The two shapes genuinely diverge. Attributes: id: Server-assigned identifier. @@ -820,8 +843,8 @@ class SpaceDetail(_Base): `token_multiplier` and is what `GuardClient.estimate_tokens` reads. max_media_size: Largest accepted upload in bytes when the space caps it. enabled_media: Which media categories may be submitted. - enabled_tasks: The enabled detections as full objects rather than names. - task_thresholds: Per-task blur and hide cut-offs. + enabled_targets: The enabled detections as full objects rather than names. + target_thresholds: Per-target blur and hide cut-offs. """ id: UUID @@ -845,8 +868,8 @@ class SpaceDetail(_Base): ) max_media_size: Optional[int] = Field(default=None, description="Bytes") enabled_media: List[MediaCategory] = Field(default_factory=list) - enabled_tasks: List[Task] = Field(default_factory=list) - task_thresholds: List[SpaceThresholds] = Field(default_factory=list) + enabled_targets: List[Target] = Field(default_factory=list) + target_thresholds: List[SpaceThresholds] = Field(default_factory=list) @property def owner_name(self) -> Optional[str]: @@ -862,18 +885,18 @@ def owner_name(self) -> Optional[str]: class Share(_Base): """ - A public link to one task result for an activity. + A public link to one target result for a prediction. - The API allows one share per activity and offers no way to revoke it. A link lives + The API allows one share per prediction and offers no way to revoke it. A link lives until `expired_at` passes. Attributes: id: Server-assigned identifier. created_at: When the link was created. expired_at: When it stops working. See `is_expired`. - activity_id: The activity being shared. - task_id: Which task result the link shows. - task_name: That task display name in the request locale. + prediction_id: The prediction being shared. + target_id: Which target result the link shows. + target_name: That target display name in the request locale. space_name: The owning space display name. expires_in: Requested lifetime in days, 1-7. share_url: The public link to hand out. This is the point of the object. @@ -884,14 +907,14 @@ class Share(_Base): id: UUID created_at: datetime expired_at: datetime - activity_id: UUID - task_id: UUID - task_name: Optional[str] = None + prediction_id: UUID + target_id: UUID + target_name: Optional[str] = None space_name: Optional[str] = None expires_in: int = Field(default=7, description="Lifetime in days, 1-7") share_url: str media_url: Optional[str] = None - result: Optional[ActivityResultItem] = None + result: Optional[PredictionResultItem] = None @property def is_expired(self) -> bool: @@ -920,6 +943,7 @@ class Runner(_Base): id: Server-assigned identifier. status: Where the deployment has got to. May be `TERMINATED`, which cannot be used as a filter. + deployment: Whether Elhio runs it or you do. created_at: When the runner was created. terminated_at: When it was torn down, or `None` while it lives. name: Display name. @@ -933,6 +957,7 @@ class Runner(_Base): id: UUID status: RunnerStatus + deployment: RunnerDeployment = RunnerDeployment.MANAGED created_at: datetime terminated_at: Optional[datetime] = None name: str @@ -1021,12 +1046,12 @@ def has_more(self) -> bool: #: One page of spaces as returned by `Spaces.list`. SpacePage = Page[Space] -#: One page of activities as returned by `Activities.list`. -ActivityPage = Page[Activity] +#: One page of predictions as returned by `Predictions.list`. +PredictionPage = Page[Prediction] #: One page of predictors as returned by `Predictors.list`. PredictorPage = Page[Predictor] -#: One page of tasks as returned by `Tasks.list`. -TaskPage = Page[Task] +#: One page of targets as returned by `Targets.list`. +TargetPage = Page[Target] #: One page of runners as returned by `Runners.list`. RunnerPage = Page[Runner] #: One page of shares as returned by `Shares.list`. @@ -1045,74 +1070,74 @@ class DetectionResult(_Base): """ engine: Engine - results: List[ActivityResultItem] = Field(default_factory=list) - activity_id: Optional[UUID] = Field( - default=None, description="None for local runs, which create no activity" + results: List[PredictionResultItem] = Field(default_factory=list) + prediction_id: Optional[UUID] = Field( + default=None, description="None for local runs, which create no prediction" ) @property def max_score(self) -> int: """ - The strongest detection across every task. + The strongest detection across every target. - This is useful as a single number to threshold on when the specific task matters - less than whether anything fired. + This is useful as a single number to threshold on when the specific target + matters less than whether anything fired. Returns: The highest score in `results` or 0 when there are none. """ return max((item.score for item in self.results), default=0) - def score_for(self, task_id: UUID) -> Optional[int]: + def score_for(self, target_id: UUID) -> Optional[int]: """ - Look up one task score. + Look up one target score. Args: - task_id: Which detection to read. + target_id: Which detection to read. Returns: - That task score, or `None` when the task is absent from the results. This is - different from a score of 0. + That target score, or `None` when the target is absent from the results. + This is different from a score of 0. """ return next( - (item.score for item in self.results if item.task_id == task_id), None + (item.score for item in self.results if item.target_id == target_id), None ) -#: A type alias for anything carrying the scored results of an activity, whichever call -#: produced it. `GuardClient.analyze` returns a `DetectionResult` and `activities.get()` -#: returns an `ActivityDetail`. They spell the same two things differently, which the -#: helper functions below reconcile. -ResultSource = Union[DetectionResult, ActivityDetail] +#: A type alias for anything carrying the scored results of a prediction, whichever call +#: produced it. `GuardClient.analyze` returns a `DetectionResult` and +#: `predictions.get()` returns a `PredictionDetail`. They spell the same two things +#: differently, which the helper functions below reconcile. +ResultSource = Union[DetectionResult, PredictionDetail] -def activity_id_of(source: ResultSource) -> Optional[UUID]: +def prediction_id_of(source: ResultSource) -> Optional[UUID]: """ - Extract the activity id behind a result. + Extract the prediction id behind a result. Args: source: The result source to examine. Returns: - The activity id behind the result, or `None` when there is no server-side - activity. `None` means it is a local-engine `DetectionResult`. Nothing was + The prediction id behind the result, or `None` when there is no server-side + prediction. `None` means it is a local-engine `DetectionResult`. Nothing was created on the server, so there is nothing to react to or share. """ if isinstance(source, DetectionResult): - return source.activity_id + return source.prediction_id return source.id -def result_items_of(source: ResultSource) -> List[ActivityResultItem]: +def result_items_of(source: ResultSource) -> List[PredictionResultItem]: """ Pull the scored items out of either result shape. Args: - source: A `DetectionResult` from `analyze()` or an `ActivityDetail` from - `activities.get()`. + source: A `DetectionResult` from `analyze()` or a `PredictionDetail` from + `predictions.get()`. Returns: - The result items. This is empty when the activity is still processing because + The result items. This is empty when the prediction is still processing because `result_payload` is only populated once it completes. """ if isinstance(source, DetectionResult): diff --git a/src/guard_client/activities.py b/src/guard_client/predictions.py similarity index 60% rename from src/guard_client/activities.py rename to src/guard_client/predictions.py index 9121854..8dc96f3 100644 --- a/src/guard_client/activities.py +++ b/src/guard_client/predictions.py @@ -1,8 +1,8 @@ """ -Low-level bindings for the activity endpoints. +Low-level bindings for the prediction endpoints. -`Activities` and `AsyncActivities` provide synchronous and asynchronous interfaces for -the API. Both share `_ActivitiesBase` for request building to guarantee identical +`Predictions` and `AsyncPredictions` provide synchronous and asynchronous interfaces for +the API. Both share `_PredictionsBase` for request building to guarantee identical behavior. """ @@ -13,7 +13,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union from uuid import UUID -from .exceptions import ActivityFailedError, GuardError, GuardTimeoutError +from .exceptions import GuardError, GuardTimeoutError, PredictionFailedError from .filters import ( MAX_LIMIT, DateLike, @@ -27,33 +27,33 @@ ) from .media import MediaSource, resolve_media from .models import ( - Activity, - ActivityCreateResponse, - ActivityDetail, - ActivityOrder, - ActivityPage, - ActivityStatus, - ActivityStatusResponse, MediaType, + Prediction, + PredictionCreateResponse, + PredictionDetail, + PredictionOrder, + PredictionPage, + PredictionStatus, + PredictionStatusResponse, PresignedUploadData, SortOrder, ) from .transport import AsyncTransport, SyncTransport -__all__ = ["Activities", "AsyncActivities", "IdLike"] +__all__ = ["Predictions", "AsyncPredictions", "IdLike"] -#: The base URL path for activity endpoints. -_BASE = "/api/v1/activities/" +#: The base URL path for prediction endpoints. +_BASE = "/api/v1/predictions" -#: Default seconds to wait between status checks when polling an activity. +#: Default seconds to wait between status checks when polling a prediction. DEFAULT_POLL_INTERVAL = 2 -#: Default maximum seconds to wait for an activity to complete before raising a timeout +#: Default maximum seconds to wait for a prediction to complete before raising a timeout #: error. DEFAULT_POLL_TIMEOUT = 90.0 -class _ActivitiesBase: +class _PredictionsBase: """ Handles request and response shaping without performing network I/O. @@ -102,15 +102,20 @@ def _create_payload( space_id: Optional[IdLike], user_id: Optional[IdLike], account_id: Optional[IdLike], + width: Optional[int] = None, + height: Optional[int] = None, + duration_seconds: Optional[float] = None, ) -> Dict[str, Any]: """ - Build the body for creating an activity. + Build the body for creating a prediction. Returns: - The request body, omitting every owner id that was not supplied. + The request body, omitting every owner id and dimension that was not + supplied. Raises: - GuardError: No space id is available from the call or the client default. + GuardError: No space id is available from the call or the client default, + or a dimension is invalid. """ resolved_type = ( media_type.value if isinstance(media_type, MediaType) else media_type @@ -126,6 +131,7 @@ def _create_payload( ): if value is not None: payload[key] = str(value) + payload.update(_dimensions(width, height, duration_seconds)) return payload @staticmethod @@ -136,8 +142,8 @@ def _list_params( space_id: Optional[IdLike], start_date: Optional[DateLike], end_date: Optional[DateLike], - statuses: Optional[Sequence[Union[ActivityStatus, str]]], - sort_by: Optional[Union[ActivityOrder, str]], + statuses: Optional[Sequence[Union[PredictionStatus, str]]], + sort_by: Optional[Union[PredictionOrder, str]], sort_order: Optional[Union[SortOrder, str]], skip: int, limit: int, @@ -163,28 +169,69 @@ def _list_params( ) add_datetime(params, "start_date", start_date) add_datetime(params, "end_date", end_date) - add_statuses(params, statuses, ActivityStatus) - add_sort(params, sort_by, sort_order, ActivityOrder) + add_statuses(params, statuses, PredictionStatus) + add_sort(params, sort_by, sort_order, PredictionOrder) return params @staticmethod def _check_terminal( - status: ActivityStatusResponse, activity_id: IdLike - ) -> Optional[ActivityStatusResponse]: + status: PredictionStatusResponse, prediction_id: IdLike + ) -> Optional[PredictionStatusResponse]: """ - Return the activity status if valid, raising on failure. + Return the prediction status if valid, raising on failure. """ - if status.status is ActivityStatus.COMPLETED: + if status.status is PredictionStatus.COMPLETED: return status - if status.status in (ActivityStatus.FAILED, ActivityStatus.CANCELED): - raise ActivityFailedError( - f"Activity {activity_id} {status.status.value}", + if status.status in (PredictionStatus.FAILED, PredictionStatus.CANCELED): + raise PredictionFailedError( + f"Prediction {prediction_id} {status.status.value}", status=status.status.value, - activity_id=_as_uuid(activity_id), + prediction_id=_as_uuid(prediction_id), ) return None +def _dimensions( + width: Optional[int], height: Optional[int], duration_seconds: Optional[float] +) -> Dict[str, Any]: + """ + Validate the declared media dimensions. + + The API reserves tokens from these and rejects media whose long side has no price + before anything is uploaded. Without them it reserves the smallest possible + prediction and only learns the real cost from the runner. + + Args: + width: Width in pixels, or `None` when unknown. + height: Height in pixels, or `None` when unknown. + duration_seconds: Video length in seconds, or `None` for a still image or when + unknown. + + Returns: + The body fields for the values that were supplied. + + Raises: + GuardError: Only one of `width` and `height` was given, a side is not a + positive integer, or the duration is negative. + """ + if (width is None) != (height is None): + raise GuardError("width and height must be given together") + fields: Dict[str, Any] = {} + if width is not None and height is not None: + for name, side in (("width", width), ("height", height)): + if isinstance(side, bool) or not isinstance(side, int) or side <= 0: + raise GuardError(f"Invalid {name}={side!r}. Expected a positive int") + fields["width"] = width + fields["height"] = height + if duration_seconds is not None: + if isinstance(duration_seconds, bool) or duration_seconds < 0: + raise GuardError( + f"Invalid duration_seconds={duration_seconds!r}. Expected 0 or greater" + ) + fields["duration_seconds"] = float(duration_seconds) + return fields + + def _as_uuid(value: IdLike) -> Optional[UUID]: """ Return as UUID. @@ -204,9 +251,9 @@ def _as_uuid(value: IdLike) -> Optional[UUID]: return None -class Activities(_ActivitiesBase): +class Predictions(_PredictionsBase): """ - Synchronous activity endpoints. + Synchronous prediction endpoints. This is reached through the client rather than constructed directly, and it shares the client's connection pool. @@ -234,27 +281,36 @@ def create( space_id: Optional[IdLike] = None, user_id: Optional[IdLike] = None, account_id: Optional[IdLike] = None, - ) -> ActivityCreateResponse: + width: Optional[int] = None, + height: Optional[int] = None, + duration_seconds: Optional[float] = None, + ) -> PredictionCreateResponse: """ - Create an activity and get back its presigned upload target. + Create a prediction and get back its presigned upload target. Args: media_type: MIME type of the media you will upload. media_size: Its size in bytes. space_id: Overrides the client default space id. - user_id: Owning user, for a user-owned activity. + user_id: Owning user, for a user-owned prediction. account_id: Owning service account. + width: Media width in pixels. Give it with `height` so the API reserves + the real cost rather than the smallest possible one. + height: Media height in pixels. + duration_seconds: Video length in seconds. Returns: - The created activity, including the one-time `upload_data`. + The created prediction, including the one-time `upload_data`. Raises: - GuardError: No space id is available. - GuardAPIError: The API rejected the request. + GuardError: No space id is available, or a dimension is invalid. + GuardAPIError: The API rejected the request, for example with a 413 when + the media is larger than the space allows or its long side has no + price. Note: This request is safe to replay and is the only POST the client retries. - A duplicate create leaves an unused activity rather than performing an + A duplicate create leaves an unused prediction rather than performing an action twice. """ payload = self._create_payload( @@ -263,9 +319,12 @@ def create( space_id=space_id, user_id=user_id, account_id=account_id, + width=width, + height=height, + duration_seconds=duration_seconds, ) data = self._transport.request("POST", _BASE, json=payload, retry=True) - return ActivityCreateResponse.model_validate(data) + return PredictionCreateResponse.model_validate(data) def upload( self, @@ -291,57 +350,57 @@ def upload( data, _, name = resolve_media(source, media_type=media_type, filename=filename) self._transport.upload(upload_data.url, upload_data.fields, name, data) - def confirm(self, activity_id: IdLike) -> Activity: + def confirm(self, prediction_id: IdLike) -> Prediction: """ - Confirm the upload, which moves the activity into processing. + Confirm the upload, which moves the prediction into processing. Args: - activity_id: The activity whose media has been uploaded. + prediction_id: The prediction whose media has been uploaded. Returns: - The activity, which is now processing. + The prediction, which is now processing. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = self._transport.request("POST", f"{_BASE}{activity_id}/confirm") - return Activity.model_validate(data) + data = self._transport.request("POST", f"{_BASE}/{prediction_id}/confirm") + return Prediction.model_validate(data) - def get_status(self, activity_id: IdLike) -> ActivityStatusResponse: + def get_status(self, prediction_id: IdLike) -> PredictionStatusResponse: """ - Read just enough of an activity to know its current state. + Read just enough of a prediction to know its current state. - This response is deliberately smaller than `get` so polling an activity does + This response is deliberately smaller than `get` so polling a prediction does not repeatedly transfer its results. Args: - activity_id: The activity to check. + prediction_id: The prediction to check. Returns: - The id and current status of the activity. + The id and current status of the prediction. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = self._transport.request("GET", f"{_BASE}{activity_id}/status") - return ActivityStatusResponse.model_validate(data) + data = self._transport.request("GET", f"{_BASE}/{prediction_id}/status") + return PredictionStatusResponse.model_validate(data) - def get(self, activity_id: IdLike) -> ActivityDetail: + def get(self, prediction_id: IdLike) -> PredictionDetail: """ - Read an activity in full, including its results. + Read a prediction in full, including its results. Args: - activity_id: The activity to fetch. + prediction_id: The prediction to fetch. Returns: - The activity. `result_payload` is `None` until processing finishes, and + The prediction. `result_payload` is `None` until processing finishes, and `payed_tokens` carries the actual cost. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = self._transport.request("GET", f"{_BASE}{activity_id}") - return ActivityDetail.model_validate(data) + data = self._transport.request("GET", f"{_BASE}/{prediction_id}") + return PredictionDetail.model_validate(data) def list( self, @@ -351,33 +410,33 @@ def list( space_id: Optional[IdLike] = None, start_date: Optional[DateLike] = None, end_date: Optional[DateLike] = None, - statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, - sort_by: Optional[Union[ActivityOrder, str]] = None, + statuses: Optional[Sequence[Union[PredictionStatus, str]]] = None, + sort_by: Optional[Union[PredictionOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, limit: int = MAX_LIMIT, - ) -> ActivityPage: + ) -> PredictionPage: """ - List activities matching the given filters. + List predictions matching the given filters. Args: - user_id: Only this user's activities. This may be combined with + user_id: Only this user's predictions. This may be combined with `organization_id`, and the API will apply both. - organization_id: Only this organization's activities. - space_id: Only activities in this space. + organization_id: Only this organization's predictions. + space_id: Only predictions in this space. start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only one year of history. Anything older is rejected locally before the request is sent. Omitting it defaults to exactly one year ago. A naive datetime is interpreted as UTC to match the server. end_date: Accepts the same types as start_date. Defaults to now. - statuses: Keep only activities with these statuses. + statuses: Keep only predictions with these statuses. sort_by: Valid options include `"created_at"`. Server default: `created_at`. - sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + sort_order: `"asc"` or `"desc"`. Server default for predictions: `desc`. skip: Offset, 0 or greater. limit: Page size, 1-100. Returns: - An `ActivityPage`. You can iterate it like a list, or read `.count` for the + A `PredictionPage`. You can iterate it like a list, or read `.count` for the total matching the filter across all pages. """ params = self._list_params( @@ -393,7 +452,7 @@ def list( limit=limit, ) data = self._transport.request("GET", _BASE, params=params) - return ActivityPage.model_validate(data) + return PredictionPage.model_validate(data) def iter_all( self, @@ -403,31 +462,31 @@ def iter_all( space_id: Optional[IdLike] = None, start_date: Optional[DateLike] = None, end_date: Optional[DateLike] = None, - statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, - sort_by: Optional[Union[ActivityOrder, str]] = None, + statuses: Optional[Sequence[Union[PredictionStatus, str]]] = None, + sort_by: Optional[Union[PredictionOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, - ) -> Iterator[Activity]: + ) -> Iterator[Prediction]: """ - Yield every matching activity by fetching pages as needed. + Yield every matching prediction by fetching pages as needed. Args: - user_id: Only this user's activities. This may be combined with + user_id: Only this user's predictions. This may be combined with `organization_id`, and the API will apply both. - organization_id: Only this organization's activities. - space_id: Only activities in this space. + organization_id: Only this organization's predictions. + space_id: Only predictions in this space. start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only one year of history. Anything older is rejected locally before the request is sent. Omitting it defaults to exactly one year ago. A naive datetime is interpreted as UTC to match the server. end_date: Accepts the same types as start_date. Defaults to now. - statuses: Keep only activities with these statuses. + statuses: Keep only predictions with these statuses. sort_by: Valid options include `"created_at"`. Server default: `created_at`. - sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + sort_order: `"asc"` or `"desc"`. Server default for predictions: `desc`. page_size: Page size, 1-100. Yields: - Each matching activity, starting with the oldest page first. + Each matching prediction, starting with the oldest page first. Note: Pages are fetched lazily. Breaking out of the loop early stops the requests @@ -457,50 +516,50 @@ def iter_all( def wait_until_done( self, - activity_id: IdLike, + prediction_id: IdLike, *, interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_POLL_TIMEOUT, - ) -> ActivityStatusResponse: + ) -> PredictionStatusResponse: """ - Poll until the activity completes. + Poll until the prediction completes. Args: - activity_id: The activity to check. + prediction_id: The prediction to check. interval: Seconds to wait between polling requests. timeout: Maximum seconds to wait before raising an error. Returns: - The terminal status, which is always `completed`. An activity that ended as + The terminal status, which is always `completed`. A prediction that ended as `failed` or `canceled` raises rather than returning, so a value coming back from here needs no further checking. Raises: - ActivityFailedError: The activity ended as `failed` or `canceled`. + PredictionFailedError: The prediction ended as `failed` or `canceled`. GuardTimeoutError: The specified timeout seconds elapsed without reaching a terminal status. """ deadline = time.monotonic() + timeout while True: - status = self.get_status(activity_id) - done = self._check_terminal(status, activity_id) + status = self.get_status(prediction_id) + done = self._check_terminal(status, prediction_id) if done is not None: return done if time.monotonic() + interval >= deadline: raise GuardTimeoutError( - f"Activity {activity_id} did not complete within {timeout}s " + f"Prediction {prediction_id} did not complete within {timeout}s " f"(last status: {status.status.value})", - activity_id=_as_uuid(activity_id), + prediction_id=_as_uuid(prediction_id), ) time.sleep(interval) -class AsyncActivities(_ActivitiesBase): +class AsyncPredictions(_PredictionsBase): """ - Asynchronous activity endpoints. + Asynchronous prediction endpoints. This is reached through the client rather than constructed directly, and it shares - the client's connection pool. It mirrors `Activities` method for method. Review the + the client's connection pool. It mirrors `Predictions` method for method. Review the synchronous methods for argument details. """ @@ -526,28 +585,37 @@ async def create( space_id: Optional[IdLike] = None, user_id: Optional[IdLike] = None, account_id: Optional[IdLike] = None, - ) -> ActivityCreateResponse: + width: Optional[int] = None, + height: Optional[int] = None, + duration_seconds: Optional[float] = None, + ) -> PredictionCreateResponse: """ - Create an activity and get back its presigned upload target. + Create a prediction and get back its presigned upload target. Args: media_type: MIME type of the media you will upload. media_size: Its size in bytes. space_id: Overrides the client default space id. - user_id: Owning user, for a user-owned activity. + user_id: Owning user, for a user-owned prediction. account_id: Owning service account. + width: Media width in pixels. Give it with `height` so the API reserves + the real cost rather than the smallest possible one. + height: Media height in pixels. + duration_seconds: Video length in seconds. Returns: - The created activity, including the one-time `upload_data`. + The created prediction, including the one-time `upload_data`. Raises: - GuardError: No space id is available. - GuardAPIError: The API rejected the request. + GuardError: No space id is available, or a dimension is invalid. + GuardAPIError: The API rejected the request, for example with a 413 when + the media is larger than the space allows or its long side has no + price. Note: This request is safe to replay and is the only POST the client retries. A - duplicate create leaves an unused activity rather than performing an action - twice. + duplicate create leaves an unused prediction rather than performing an + action twice. """ payload = self._create_payload( media_type=media_type, @@ -555,9 +623,12 @@ async def create( space_id=space_id, user_id=user_id, account_id=account_id, + width=width, + height=height, + duration_seconds=duration_seconds, ) data = await self._transport.request("POST", _BASE, json=payload, retry=True) - return ActivityCreateResponse.model_validate(data) + return PredictionCreateResponse.model_validate(data) async def upload( self, @@ -583,57 +654,57 @@ async def upload( data, _, name = resolve_media(source, media_type=media_type, filename=filename) await self._transport.upload(upload_data.url, upload_data.fields, name, data) - async def confirm(self, activity_id: IdLike) -> Activity: + async def confirm(self, prediction_id: IdLike) -> Prediction: """ - Confirm the upload, which moves the activity into processing. + Confirm the upload, which moves the prediction into processing. Args: - activity_id: The activity whose media has been uploaded. + prediction_id: The prediction whose media has been uploaded. Returns: - The activity, which is now processing. + The prediction, which is now processing. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = await self._transport.request("POST", f"{_BASE}{activity_id}/confirm") - return Activity.model_validate(data) + data = await self._transport.request("POST", f"{_BASE}/{prediction_id}/confirm") + return Prediction.model_validate(data) - async def get_status(self, activity_id: IdLike) -> ActivityStatusResponse: + async def get_status(self, prediction_id: IdLike) -> PredictionStatusResponse: """ - Read just enough of an activity to know its current state. + Read just enough of a prediction to know its current state. - This response is deliberately smaller than `get` so polling an activity does not - repeatedly transfer its results. + This response is deliberately smaller than `get` so polling a prediction does + not repeatedly transfer its results. Args: - activity_id: The activity to check. + prediction_id: The prediction to check. Returns: - The id and current status of the activity. + The id and current status of the prediction. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = await self._transport.request("GET", f"{_BASE}{activity_id}/status") - return ActivityStatusResponse.model_validate(data) + data = await self._transport.request("GET", f"{_BASE}/{prediction_id}/status") + return PredictionStatusResponse.model_validate(data) - async def get(self, activity_id: IdLike) -> ActivityDetail: + async def get(self, prediction_id: IdLike) -> PredictionDetail: """ - Read an activity in full, including its results. + Read a prediction in full, including its results. Args: - activity_id: The activity to fetch. + prediction_id: The prediction to fetch. Returns: - The activity. `result_payload` is `None` until processing finishes, and + The prediction. `result_payload` is `None` until processing finishes, and `payed_tokens` carries the actual cost. Raises: - GuardNotFoundError: Unknown activity, or not owned by you. + GuardNotFoundError: Unknown prediction, or not owned by you. """ - data = await self._transport.request("GET", f"{_BASE}{activity_id}") - return ActivityDetail.model_validate(data) + data = await self._transport.request("GET", f"{_BASE}/{prediction_id}") + return PredictionDetail.model_validate(data) async def list( self, @@ -643,33 +714,33 @@ async def list( space_id: Optional[IdLike] = None, start_date: Optional[DateLike] = None, end_date: Optional[DateLike] = None, - statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, - sort_by: Optional[Union[ActivityOrder, str]] = None, + statuses: Optional[Sequence[Union[PredictionStatus, str]]] = None, + sort_by: Optional[Union[PredictionOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, limit: int = MAX_LIMIT, - ) -> ActivityPage: + ) -> PredictionPage: """ - List activities matching the given filters. + List predictions matching the given filters. Args: - user_id: Only this user's activities. This may be combined with + user_id: Only this user's predictions. This may be combined with `organization_id`, and the API will apply both. - organization_id: Only this organization's activities. - space_id: Only activities in this space. + organization_id: Only this organization's predictions. + space_id: Only predictions in this space. start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only one year of history. Anything older is rejected locally before the request is sent. Omitting it defaults to exactly one year ago. A naive datetime is interpreted as UTC to match the server. end_date: Accepts the same types as start_date. Defaults to now. - statuses: Keep only activities with these statuses. + statuses: Keep only predictions with these statuses. sort_by: Valid options include `"created_at"`. Server default: `created_at`. - sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + sort_order: `"asc"` or `"desc"`. Server default for predictions: `desc`. skip: Offset, 0 or greater. limit: Page size, 1-100. Returns: - An `ActivityPage`. You can iterate it like a list, or read `.count` for the + A `PredictionPage`. You can iterate it like a list, or read `.count` for the total matching the filter across all pages. """ params = self._list_params( @@ -685,7 +756,7 @@ async def list( limit=limit, ) data = await self._transport.request("GET", _BASE, params=params) - return ActivityPage.model_validate(data) + return PredictionPage.model_validate(data) async def iter_all( self, @@ -695,31 +766,31 @@ async def iter_all( space_id: Optional[IdLike] = None, start_date: Optional[DateLike] = None, end_date: Optional[DateLike] = None, - statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, - sort_by: Optional[Union[ActivityOrder, str]] = None, + statuses: Optional[Sequence[Union[PredictionStatus, str]]] = None, + sort_by: Optional[Union[PredictionOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, - ) -> AsyncIterator[Activity]: + ) -> AsyncIterator[Prediction]: """ - Yield every matching activity by fetching pages as needed. + Yield every matching prediction by fetching pages as needed. Args: - user_id: Only this user's activities. This may be combined with + user_id: Only this user's predictions. This may be combined with `organization_id`, and the API will apply both. - organization_id: Only this organization's activities. - space_id: Only activities in this space. + organization_id: Only this organization's predictions. + space_id: Only predictions in this space. start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only one year of history. Anything older is rejected locally before the request is sent. Omitting it defaults to exactly one year ago. A naive datetime is interpreted as UTC to match the server. end_date: Accepts the same types as start_date. Defaults to now. - statuses: Keep only activities with these statuses. + statuses: Keep only predictions with these statuses. sort_by: Valid options include `"created_at"`. Server default: `created_at`. - sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + sort_order: `"asc"` or `"desc"`. Server default for predictions: `desc`. page_size: Page size, 1-100. Yields: - Each matching activity, starting with the oldest page first. + Each matching prediction, starting with the oldest page first. Note: Pages are fetched lazily. Breaking out of the loop early stops the requests @@ -739,8 +810,8 @@ async def iter_all( skip=skip, limit=page_size, ) - for activity in page.data: - yield activity + for prediction in page.data: + yield prediction skip += len(page.data) if not page.data or len(page.data) < page_size or skip >= page.count: @@ -748,24 +819,24 @@ async def iter_all( async def wait_until_done( self, - activity_id: IdLike, + prediction_id: IdLike, *, interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_POLL_TIMEOUT, - ) -> ActivityStatusResponse: + ) -> PredictionStatusResponse: """ - Poll until the activity completes. + Poll until the prediction completes. Args: - activity_id: The activity to check. + prediction_id: The prediction to check. interval: Seconds to wait between polling requests. timeout: Maximum seconds to wait before raising an error. Returns: - The terminal status. Check `Activities.wait_until_done` for reference. + The terminal status. Check `Predictions.wait_until_done` for reference. Raises: - ActivityFailedError: The activity ended as `failed` or `canceled`. + PredictionFailedError: The prediction ended as `failed` or `canceled`. GuardTimeoutError: The deadline passed while it was still running. Note: @@ -774,14 +845,14 @@ async def wait_until_done( """ deadline = time.monotonic() + timeout while True: - status = await self.get_status(activity_id) - done = self._check_terminal(status, activity_id) + status = await self.get_status(prediction_id) + done = self._check_terminal(status, prediction_id) if done is not None: return done if time.monotonic() + interval >= deadline: raise GuardTimeoutError( - f"Activity {activity_id} did not complete within {timeout}s " + f"Prediction {prediction_id} did not complete within {timeout}s " f"(last status: {status.status.value})", - activity_id=_as_uuid(activity_id), + prediction_id=_as_uuid(prediction_id), ) await asyncio.sleep(interval) diff --git a/src/guard_client/predictors.py b/src/guard_client/predictors.py index 58a2dda..9a6a7a2 100644 --- a/src/guard_client/predictors.py +++ b/src/guard_client/predictors.py @@ -1,5 +1,5 @@ """ -Low-level bindings for the `/api/v1/predictors/` endpoints. +Low-level bindings for the `/api/v1/predictors` endpoints. A predictor is the model that powers a space, and its ID is required to create one. Listing predictors is how a caller finds a valid `predictor_id`. @@ -24,7 +24,7 @@ __all__ = ["AsyncPredictors", "Predictors"] #: The base URL path for predictor endpoints. -_BASE = "/api/v1/predictors/" +_BASE = "/api/v1/predictors" class _PredictorsBase: @@ -40,7 +40,7 @@ def _list_params( *, user_id: Optional[IdLike], organization_id: Optional[IdLike], - supported_task_ids: Optional[Sequence[IdLike]], + supported_target_ids: Optional[Sequence[IdLike]], sort_by: Optional[Union[PredictorOrder, str]], sort_order: Optional[Union[SortOrder, str]], skip: int, @@ -55,7 +55,7 @@ def _list_params( Args: user_id: Only predictors available to this user. organization_id: Only predictors available to this organization. - supported_task_ids: Only predictors supporting all of these tasks. + supported_target_ids: Only predictors supporting all of these targets. sort_by: The field to sort the results by. sort_order: The direction to sort the results. skip: Offset of the results. Must be 0 or greater. @@ -72,9 +72,9 @@ def _list_params( params: Dict[str, Any] = {"skip": skip, "limit": limit} add_ids(params, user_id=user_id, organization_id=organization_id) - task_ids = id_list(supported_task_ids, field="supported_task_ids") - if task_ids: - params["supported_task_ids"] = task_ids + target_ids = id_list(supported_target_ids, field="supported_target_ids") + if target_ids: + params["supported_target_ids"] = target_ids add_sort(params, sort_by, sort_order, PredictorOrder) return params @@ -101,7 +101,7 @@ def list( *, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - supported_task_ids: Optional[Sequence[IdLike]] = None, + supported_target_ids: Optional[Sequence[IdLike]] = None, sort_by: Optional[Union[PredictorOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, @@ -113,7 +113,7 @@ def list( Args: user_id: Only predictors available to this user. organization_id: Only predictors available to this organization. - supported_task_ids: Only predictors supporting all of these tasks. + supported_target_ids: Only predictors supporting all of these targets. sort_by: Valid options include `"name"` or `"created_at"`. Server default: `"name"`. sort_order: `"asc"` or `"desc"`. Server default: `"asc"`. @@ -129,7 +129,7 @@ def list( params = self._list_params( user_id=user_id, organization_id=organization_id, - supported_task_ids=supported_task_ids, + supported_target_ids=supported_target_ids, sort_by=sort_by, sort_order=sort_order, skip=skip, @@ -143,7 +143,7 @@ def iter_all( *, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - supported_task_ids: Optional[Sequence[IdLike]] = None, + supported_target_ids: Optional[Sequence[IdLike]] = None, sort_by: Optional[Union[PredictorOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, @@ -154,7 +154,7 @@ def iter_all( Args: user_id: Only predictors available to this user. organization_id: Only predictors available to this organization. - supported_task_ids: Only predictors supporting all of these tasks. + supported_target_ids: Only predictors supporting all of these targets. sort_by: Valid options include `"name"` or `"created_at"`. sort_order: `"asc"` or `"desc"`. page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. @@ -171,7 +171,7 @@ def iter_all( page = self.list( user_id=user_id, organization_id=organization_id, - supported_task_ids=supported_task_ids, + supported_target_ids=supported_target_ids, sort_by=sort_by, sort_order=sort_order, skip=skip, @@ -206,7 +206,7 @@ async def list( *, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - supported_task_ids: Optional[Sequence[IdLike]] = None, + supported_target_ids: Optional[Sequence[IdLike]] = None, sort_by: Optional[Union[PredictorOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, @@ -218,7 +218,7 @@ async def list( Args: user_id: Only predictors available to this user. organization_id: Only predictors available to this organization. - supported_task_ids: Only predictors supporting all of these tasks. + supported_target_ids: Only predictors supporting all of these targets. sort_by: Valid options include `"name"` or `"created_at"`. sort_order: `"asc"` or `"desc"`. skip: Offset. 0 or greater. @@ -233,7 +233,7 @@ async def list( params = self._list_params( user_id=user_id, organization_id=organization_id, - supported_task_ids=supported_task_ids, + supported_target_ids=supported_target_ids, sort_by=sort_by, sort_order=sort_order, skip=skip, @@ -247,7 +247,7 @@ async def iter_all( *, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - supported_task_ids: Optional[Sequence[IdLike]] = None, + supported_target_ids: Optional[Sequence[IdLike]] = None, sort_by: Optional[Union[PredictorOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, @@ -258,7 +258,7 @@ async def iter_all( Args: user_id: Only predictors available to this user. organization_id: Only predictors available to this organization. - supported_task_ids: Only predictors supporting all of these tasks. + supported_target_ids: Only predictors supporting all of these targets. sort_by: Valid options include `"name"` or `"created_at"`. sort_order: `"asc"` or `"desc"`. page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. @@ -272,7 +272,7 @@ async def iter_all( page = await self.list( user_id=user_id, organization_id=organization_id, - supported_task_ids=supported_task_ids, + supported_target_ids=supported_target_ids, sort_by=sort_by, sort_order=sort_order, skip=skip, diff --git a/src/guard_client/reactions.py b/src/guard_client/reactions.py index 4c6f0a1..f8fa2b5 100644 --- a/src/guard_client/reactions.py +++ b/src/guard_client/reactions.py @@ -1,12 +1,13 @@ """ -Low-level bindings for the `/api/v1/reactions/` endpoint. +Low-level bindings for the `/api/v1/reactions` endpoint. -A reaction is feedback on one task result for one activity. It answers whether the +A reaction is feedback on one target result for one prediction. It answers whether the detection was right, and if not, what the expected result was. -Two API rules shape this module: First, you may only react to your own activity. +Two API rules shape this module: First, you may only react to your own prediction. Anything else returns a 404, which is indistinguishable from an unknown id. Second, only -one reaction is allowed per activity and task. A second attempt returns a 409 conflict. +one reaction is allowed per prediction and target. A second attempt returns a 409 +conflict. Reading or removing a reaction is an admin-only operation, so this resource is create-only. @@ -19,11 +20,11 @@ from .exceptions import GuardError from .filters import IdLike, validate_length from .models import ( - ActivityResultItem, + PredictionResultItem, Reaction, ResultSource, - activity_id_of, ensure_bool, + prediction_id_of, result_items_of, ) from .transport import AsyncTransport, SyncTransport @@ -31,7 +32,7 @@ __all__ = ["AsyncReactions", "Reactions"] #: The base URL path for the reactions endpoints. -_BASE = "/api/v1/reactions/" +_BASE = "/api/v1/reactions" #: The server-side constraint on the maximum length of a reaction description. DESCRIPTION_MAX_LENGTH = 255 @@ -48,8 +49,8 @@ class _ReactionsBase: @staticmethod def _create_payload( *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, is_positive: bool, key_value: Optional[int], description: Optional[str], @@ -60,8 +61,8 @@ def _create_payload( The arguments mirror `Reactions.create`, but none are optional here. Args: - activity_id: The ID of the activity the result belongs to. - task_id: The ID of the task result being reacted to. + prediction_id: The ID of the prediction the result belongs to. + target_id: The ID of the target result being reacted to. is_positive: Indicates if the detection was correct. key_value: The expected result as an integer key. description: A free text description. @@ -74,8 +75,8 @@ def _create_payload( integer, or `description` exceeds 255 characters. """ payload: Dict[str, Any] = { - "activity_id": str(activity_id), - "task_id": str(task_id), + "prediction_id": str(prediction_id), + "target_id": str(target_id), "is_positive": ensure_bool(is_positive, field="is_positive"), } @@ -86,8 +87,8 @@ def _create_payload( if isinstance(key_value, bool) or not isinstance(key_value, int): raise GuardError( f"Invalid key_value={key_value!r}. Expected an integer key from " - f"the task's `reactions` map, e.g. " - f"task.reactions -> {{1: 'Real photo'}}" + f"the target's `reactions` map, e.g. " + f"target.reactions -> {{1: 'Real photo'}}" ) payload["key_value"] = key_value @@ -101,36 +102,38 @@ def _create_payload( return payload @staticmethod - def _resolve_source(source: ResultSource, item: ActivityResultItem) -> IdLike: + def _resolve_source(source: ResultSource, item: PredictionResultItem) -> IdLike: """ - Get the activity id while checking the item really belongs to this result. + Get the prediction id while checking the item really belongs to this result. Args: - source: A result from `analyze()` or `activities.get()`. + source: A result from `analyze()` or `predictions.get()`. item: The result item being commented on. Returns: - The activity id behind the result. + The prediction id behind the result. Raises: - GuardError: If the source came from the local engine, meaning no activity - exists on the server, or if the item belongs to a different activity. + GuardError: If the source came from the local engine, meaning no prediction + exists on the server, or if the item belongs to a different prediction. """ - activity_id = activity_id_of(source) - if activity_id is None: + prediction_id = prediction_id_of(source) + if prediction_id is None: raise GuardError( - "This result came from the local engine, so no activity exists on the " - "server to react to. Reactions apply to cloud results only." + "This result came from the local engine, so no prediction exists on " + "the server to react to. Reactions apply to cloud results only." ) known = result_items_of(source) - if not any(existing.task_id == item.task_id for existing in known): - available = ", ".join(str(existing.task_id) for existing in known) or "none" + if not any(existing.target_id == item.target_id for existing in known): + available = ( + ", ".join(str(existing.target_id) for existing in known) or "none" + ) raise GuardError( - f"task_id {item.task_id} is not part of this activity's results " + f"target_id {item.target_id} is not part of this prediction's results " f"(available: {available})." ) - return activity_id + return prediction_id class Reactions(_ReactionsBase): @@ -153,22 +156,22 @@ def __init__(self, transport: SyncTransport) -> None: def create( self, *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, is_positive: bool, key_value: Optional[int] = None, description: Optional[str] = None, ) -> Reaction: """ - Submit feedback on one task result. + Submit feedback on one target result. Args: - activity_id: The activity the result belongs to. It must be your own. - task_id: Which task result you are reacting to. + prediction_id: The prediction the result belongs to. It must be your own. + target_id: Which target result you are reacting to. is_positive: `True` if the detection was correct. Accepts `True` or `False` only. - key_value: The expected result as an integer key of the task `reactions` - map. See `Task.reactions` for the choices. + key_value: The expected result as an integer key of the target `reactions` + map. See `Target.reactions` for the choices. description: Free text up to 255 characters. Blank counts as unset. Returns: @@ -177,15 +180,16 @@ def create( Raises: GuardError: If a value is invalid. This is raised before any request is sent. - GuardNotFoundError: If the activity is unknown or it is not yours. The API + GuardNotFoundError: If the prediction is unknown or it is not yours. The API does not distinguish between the two. - GuardConflictError: If you have already reacted to this activity and task. + GuardConflictError: If you have already reacted to this prediction and + target. Examples: ```python client.reactions.create( - activity_id=result.activity_id, - task_id=item.task_id, + prediction_id=result.prediction_id, + target_id=item.target_id, is_positive=False, key_value=2, description="This is a real photo of me", @@ -193,21 +197,21 @@ def create( ``` """ payload = self._create_payload( - activity_id=activity_id, - task_id=task_id, + prediction_id=prediction_id, + target_id=target_id, is_positive=is_positive, key_value=key_value, description=description, ) - # not retried: a replay would trip the one-reaction-per-task guard and report a - # 409 for a reaction that actually succeeded + # not retried: a replay would trip the one-reaction-per-target guard and report + # a 409 for a reaction that actually succeeded data = self._transport.request("POST", _BASE, json=payload) return Reaction.model_validate(data) def create_for( self, source: ResultSource, - item: ActivityResultItem, + item: PredictionResultItem, *, is_positive: bool, key_value: Optional[int] = None, @@ -218,10 +222,10 @@ def create_for( Args: source: Either a `DetectionResult` from `GuardClient.analyze` or an - `ActivityDetail` from `activities.get()`. - item: The `ActivityResultItem` being commented on. + `PredictionDetail` from `predictions.get()`. + item: The `PredictionResultItem` being commented on. is_positive: `True` if the detection was correct. - key_value: The expected result as an integer key of the task `reactions` + key_value: The expected result as an integer key of the target `reactions` map. description: Free text up to 255 characters. @@ -232,10 +236,10 @@ def create_for( GuardError: If the source is a local result or the item is not one of its results. Both checks happen before any request is sent. """ - activity_id = self._resolve_source(source, item) + prediction_id = self._resolve_source(source, item) return self.create( - activity_id=activity_id, - task_id=item.task_id, + prediction_id=prediction_id, + target_id=item.target_id, is_positive=is_positive, key_value=key_value, description=description, @@ -262,21 +266,21 @@ def __init__(self, transport: AsyncTransport) -> None: async def create( self, *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, is_positive: bool, key_value: Optional[int] = None, description: Optional[str] = None, ) -> Reaction: """ - Submit feedback on one task result. + Submit feedback on one target result. Args: - activity_id: The activity the result belongs to. It must be your own. - task_id: Which task result you are reacting to. + prediction_id: The prediction the result belongs to. It must be your own. + target_id: Which target result you are reacting to. is_positive: `True` if the detection was correct. Accepts `True` or `False` only. - key_value: The expected result as an integer key of the task `reactions` + key_value: The expected result as an integer key of the target `reactions` map. description: Free text up to 255 characters. Blank counts as unset. @@ -285,12 +289,12 @@ async def create( Raises: GuardError: If a value is invalid. This is raised before any request. - GuardNotFoundError: If the activity is unknown or not yours. - GuardConflictError: If you already reacted to this activity and task. + GuardNotFoundError: If the prediction is unknown or not yours. + GuardConflictError: If you already reacted to this prediction and target. """ payload = self._create_payload( - activity_id=activity_id, - task_id=task_id, + prediction_id=prediction_id, + target_id=target_id, is_positive=is_positive, key_value=key_value, description=description, @@ -302,7 +306,7 @@ async def create( async def create_for( self, source: ResultSource, - item: ActivityResultItem, + item: PredictionResultItem, *, is_positive: bool, key_value: Optional[int] = None, @@ -313,10 +317,10 @@ async def create_for( Args: source: Either a `DetectionResult` from `GuardClient.analyze` or an - `ActivityDetail` from `activities.get()`. - item: The `ActivityResultItem` being commented on. + `PredictionDetail` from `predictions.get()`. + item: The `PredictionResultItem` being commented on. is_positive: `True` if the detection was correct. - key_value: The expected result as an integer key of the task `reactions` + key_value: The expected result as an integer key of the target `reactions` map. description: Free text up to 255 characters. @@ -327,10 +331,10 @@ async def create_for( GuardError: If the source is a local result or the item is not one of its results. """ - activity_id = self._resolve_source(source, item) + prediction_id = self._resolve_source(source, item) return await self.create( - activity_id=activity_id, - task_id=item.task_id, + prediction_id=prediction_id, + target_id=item.target_id, is_positive=is_positive, key_value=key_value, description=description, diff --git a/src/guard_client/runners.py b/src/guard_client/runners.py index aab561f..0cece0d 100644 --- a/src/guard_client/runners.py +++ b/src/guard_client/runners.py @@ -1,12 +1,12 @@ """ -Low-level bindings for the `/api/v1/runners/` endpoints. +Low-level bindings for the `/api/v1/runners` endpoints. A runner is a dedicated compute instance serving one predictor for an organization. Listing them supplies the `dedicated_runner_ids` accepted by `Spaces.create`. Note: These routes currently authenticate a *user* rather than a service account. A - service-account key will get a 404 here. The API is expected to widen this scope, + service-account key will get a 403 here. The API is expected to widen this scope, and nothing in the client will need to change when it does. """ @@ -27,10 +27,12 @@ from .models import ( FILTERABLE_RUNNER_STATUSES, Runner, + RunnerDeployment, RunnerOrder, RunnerPage, RunnerStatus, SortOrder, + coerce_enum, ensure_bool, ) from .transport import AsyncTransport, SyncTransport @@ -38,7 +40,7 @@ __all__ = ["AsyncRunners", "Runners"] #: The base URL path for runner endpoints. -_BASE = "/api/v1/runners/" +_BASE = "/api/v1/runners" #: A type alias for runner statuses. StatusLike = Union[RunnerStatus, str] @@ -140,6 +142,7 @@ def _create_payload( organization_id: Optional[IdLike], is_default: bool, dedicated_space_ids: Optional[Sequence[IdLike]], + deployment: Union[RunnerDeployment, str] = RunnerDeployment.MANAGED, ) -> Dict[str, Any]: """ Build the JSON body for creating a runner. @@ -151,20 +154,24 @@ def _create_payload( organization_id: The ID of the organization to own the runner. is_default: Whether spaces get this as their default runner. dedicated_space_ids: Sequence of space IDs to restrict the runner to. + deployment: Whether Elhio runs the runner or you do. Returns: The request body. Unlike spaces, `is_default` is always sent since the API accepts it here. Raises: - GuardError: If no organization id is available or `is_default` is not a - boolean. + GuardError: If no organization id is available, `is_default` is not a + boolean, or `deployment` is not a known value. """ payload: Dict[str, Any] = { "predictor_id": str(predictor_id), "organization_id": self._resolve_organization_id(organization_id), # unlike spaces, the API does accept is_default at creation here "is_default": ensure_bool(is_default, field="is_default"), + "deployment": coerce_enum( + deployment, RunnerDeployment, field="deployment" + ).value, } space_ids = id_list(dedicated_space_ids, field="dedicated_space_ids") if space_ids is not None: @@ -299,7 +306,7 @@ def get(self, runner_id: IdLike) -> Runner: GuardNotFoundError: If the runner is unknown or you are not a member of its organization. The API does not distinguish the two. """ - data = self._transport.request("GET", f"{_BASE}{runner_id}") + data = self._transport.request("GET", f"{_BASE}/{runner_id}") return Runner.model_validate(data) def create( @@ -309,6 +316,7 @@ def create( organization_id: Optional[IdLike] = None, is_default: bool = False, dedicated_space_ids: Optional[Sequence[IdLike]] = None, + deployment: Union[RunnerDeployment, str] = RunnerDeployment.MANAGED, ) -> Runner: """ Create a runner and start its deployment. @@ -323,14 +331,17 @@ def create( is_default: Whether spaces get this as their default runner. dedicated_space_ids: Restrict the runner to these spaces. They must belong to the same organization. Duplicates are dropped and order is preserved. + deployment: `managed` (the default) for a runner Elhio deploys, or + `external` for one you run yourself. Only plans that allow external + runners can create one. Returns: The created `Runner` with an initial status of `pending`. Raises: GuardError: If a value is invalid. Raised before any request is sent. - GuardPaymentRequiredError: If there is no active subscription or the runner - limit is reached. + GuardPaymentRequiredError: If there is no active subscription, the runner + limit is reached, or the plan does not allow external runners. GuardConflictError: If a runner with this name already exists here. GuardNotFoundError: If the organization, predictor, or space is unknown, or if you do not own the organization. @@ -346,6 +357,7 @@ def create( organization_id=organization_id, is_default=is_default, dedicated_space_ids=dedicated_space_ids, + deployment=deployment, ) data = self._transport.request("POST", _BASE, json=payload) return Runner.model_validate(data) @@ -368,7 +380,7 @@ def delete(self, runner_id: IdLike) -> None: """ # never retried: a replay after a dropped connection would report a confusing # 404 for a delete that actually succeeded, or re-trigger a live teardown - self._transport.request("DELETE", f"{_BASE}{runner_id}", retry=False) + self._transport.request("DELETE", f"{_BASE}/{runner_id}", retry=False) class AsyncRunners(_RunnersBase): @@ -490,7 +502,7 @@ async def get(self, runner_id: IdLike) -> Runner: GuardNotFoundError: If the runner is unknown or you are not a member of its organization. """ - data = await self._transport.request("GET", f"{_BASE}{runner_id}") + data = await self._transport.request("GET", f"{_BASE}/{runner_id}") return Runner.model_validate(data) async def create( @@ -500,6 +512,7 @@ async def create( organization_id: Optional[IdLike] = None, is_default: bool = False, dedicated_space_ids: Optional[Sequence[IdLike]] = None, + deployment: Union[RunnerDeployment, str] = RunnerDeployment.MANAGED, ) -> Runner: """ Create a runner and start its deployment. @@ -511,14 +524,17 @@ async def create( client default. is_default: Whether spaces get this as their default runner. dedicated_space_ids: Sequence of space IDs to restrict the runner to. + deployment: `managed` (the default) for a runner Elhio deploys, or + `external` for one you run yourself. Only plans that allow external + runners can create one. Returns: The created `Runner` with an initial status of `pending`. Raises: GuardError: If a value is invalid. Raised before any request is sent. - GuardPaymentRequiredError: If there is no active subscription or the runner - limit is reached. + GuardPaymentRequiredError: If there is no active subscription, the runner + limit is reached, or the plan does not allow external runners. GuardConflictError: If a runner with this name already exists here. GuardNotFoundError: If the organization, predictor, or space is unknown. """ @@ -527,6 +543,7 @@ async def create( organization_id=organization_id, is_default=is_default, dedicated_space_ids=dedicated_space_ids, + deployment=deployment, ) data = await self._transport.request("POST", _BASE, json=payload) return Runner.model_validate(data) @@ -548,4 +565,4 @@ async def delete(self, runner_id: IdLike) -> None: details. """ # never retried: see the note on Runners.delete - await self._transport.request("DELETE", f"{_BASE}{runner_id}", retry=False) + await self._transport.request("DELETE", f"{_BASE}/{runner_id}", retry=False) diff --git a/src/guard_client/shares.py b/src/guard_client/shares.py index 3dcdf0f..c232114 100644 --- a/src/guard_client/shares.py +++ b/src/guard_client/shares.py @@ -1,12 +1,12 @@ """ -Low-level bindings for the `/api/v1/activities/shares` endpoints. - -A share is a public link to one task result for an activity. Three API facts shape this -module: First, the activity must have finished processing. The task must appear in its -results, or the API answers 400 `Invalid task`. Second, only one share is allowed per -activity, and there is no delete endpoint. A link cannot be revoked; it can only be left -to expire. Third, authentication requires a full identity. As everywhere in this client, -an API key is required. +Low-level bindings for the `/api/v1/predictions/shares` endpoints. + +A share is a public link to one target result for a prediction. Three API facts shape +this module: First, the prediction must have finished processing. The target must appear +in its results, or the API answers 400 `Invalid target`. Second, only one share is +allowed per prediction, and there is no delete endpoint. A link cannot be revoked; it +can only be left to expire. Third, authentication requires a full identity. As +everywhere in this client, an API key is required. """ from __future__ import annotations @@ -23,14 +23,14 @@ validate_pagination, ) from .models import ( - ActivityResultItem, + PredictionResultItem, ResultSource, Share, ShareOrder, SharePage, ShareStatus, SortOrder, - activity_id_of, + prediction_id_of, result_items_of, ) from .transport import AsyncTransport, SyncTransport @@ -38,7 +38,7 @@ __all__ = ["AsyncShares", "Shares"] #: The base URL path for the shares endpoints. -_BASE = "/api/v1/activities/shares/" +_BASE = "/api/v1/predictions/shares" #: Server-side minimum bound on `expires_in` (days). MIN_EXPIRES_IN = 1 @@ -93,7 +93,7 @@ def _list_params( validate_pagination(skip, limit) params: Dict[str, Any] = {"skip": skip, "limit": limit} - # like /activities/ and unlike /spaces/, this route applies both owner filters + # like /predictions/ and unlike /spaces/, this route applies both owner filters # independently rather than rejecting the combination add_ids(params, user_id=user_id, organization_id=organization_id) add_statuses(params, statuses, ShareStatus) @@ -103,8 +103,8 @@ def _list_params( @staticmethod def _create_payload( *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, expires_in: Optional[int], ) -> Dict[str, Any]: """ @@ -113,8 +113,8 @@ def _create_payload( The arguments mirror `Shares.create`, but none are optional here. Args: - activity_id: The ID of the activity to share. - task_id: The ID of the task result to share. + prediction_id: The ID of the prediction to share. + target_id: The ID of the target result to share. expires_in: Lifetime in days, 1-7. Returns: @@ -125,8 +125,8 @@ def _create_payload( GuardError: If `expires_in` is not an integer or falls outside 1-7. """ payload: Dict[str, Any] = { - "activity_id": str(activity_id), - "task_id": str(task_id), + "prediction_id": str(prediction_id), + "target_id": str(target_id), } if expires_in is not None: # bool subclasses int, so True would otherwise pass as 1 day @@ -144,44 +144,46 @@ def _create_payload( return payload @staticmethod - def _resolve_source(source: ResultSource, item: ActivityResultItem) -> IdLike: + def _resolve_source(source: ResultSource, item: PredictionResultItem) -> IdLike: """ - Get the activity id while checking the item really belongs to this result. + Get the prediction id while checking the item really belongs to this result. - The membership check is the local stand-in for the API 400 `Invalid task` error, - which it raises when the task is absent from the activity results. + The membership check is the local stand-in for the API 400 `Invalid target` + error, which it raises when the target is absent from the prediction results. Args: - source: A result from `analyze()` or `activities.get()`. + source: A result from `analyze()` or `predictions.get()`. item: The result item being shared. Returns: - The activity id behind the result. + The prediction id behind the result. Raises: - GuardError: If the source came from the local engine, meaning no activity - exists on the server, or if the item belongs to a different activity. + GuardError: If the source came from the local engine, meaning no prediction + exists on the server, or if the item belongs to a different prediction. """ - activity_id = activity_id_of(source) - if activity_id is None: + prediction_id = prediction_id_of(source) + if prediction_id is None: raise GuardError( - "This result came from the local engine, so no activity exists on the " - "server to share. Shares apply to cloud results only." + "This result came from the local engine, so no prediction exists on " + "the server to share. Shares apply to cloud results only." ) known = result_items_of(source) - if not any(existing.task_id == item.task_id for existing in known): - available = ", ".join(str(existing.task_id) for existing in known) or "none" + if not any(existing.target_id == item.target_id for existing in known): + available = ( + ", ".join(str(existing.target_id) for existing in known) or "none" + ) raise GuardError( - f"task_id {item.task_id} is not part of this activity's results " - f"(available: {available}). Only a completed activity can be shared." + f"target_id {item.target_id} is not part of this prediction's results " + f"(available: {available}). Only a completed prediction can be shared." ) - return activity_id + return prediction_id class Shares(_SharesBase): """ - Synchronous activity-share endpoints. + Synchronous prediction-share endpoints. This class is accessed through the client rather than being constructed directly, and it shares the client connection pool. @@ -208,7 +210,7 @@ def list( limit: int = MAX_LIMIT, ) -> SharePage: """ - List the activity shares visible to you. + List the prediction shares visible to you. Args: user_id: Only your own ID is accepted. Anything else returns a 404. A @@ -225,8 +227,8 @@ def list( A `SharePage`. You can iterate it like a list or read `.count`. Note: - There is no `activity_id` filter. Shares cannot be looked up by the activity - they belong to. + There is no `prediction_id` filter. Shares cannot be looked up by the + prediction they belong to. """ params = self._list_params( user_id=user_id, @@ -299,23 +301,23 @@ def get(self, share_id: IdLike) -> Share: GuardNotFoundError: If the share is unknown, expired beyond visibility, or not yours. """ - data = self._transport.request("GET", f"{_BASE}{share_id}") + data = self._transport.request("GET", f"{_BASE}/{share_id}") return Share.model_validate(data) def create( self, *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, expires_in: Optional[int] = None, ) -> Share: """ - Create a public link to one task result. + Create a public link to one target result. Args: - activity_id: The activity to share. Must be your own and completed. - task_id: Which task result the link shows. It must appear in the activity - results or the API answers 400. + prediction_id: The prediction to share. Must be your own and completed. + target_id: Which target result the link shows. It must appear in the + prediction results or the API answers 400. expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 applies. @@ -324,15 +326,15 @@ def create( Raises: GuardError: If `expires_in` is out of range. Raised before any request. - GuardNotFoundError: If the activity is unknown, not yours, or its media is + GuardNotFoundError: If the prediction is unknown, not yours, or its media is gone. - GuardConflictError: If this activity has already been shared. - GuardAPIError: 400 error if the task is not in the activity results, - or if the task has no associated media. + GuardConflictError: If this prediction has already been shared. + GuardAPIError: 400 error if the target is not in the prediction results, + or if the target has no associated media. Examples: ```python - share = client.shares.create(activity_id=A, task_id=T, expires_in=1) + share = client.shares.create(prediction_id=A, target_id=T, expires_in=1) print(share.share_url) ``` @@ -340,17 +342,17 @@ def create( There is no way to revoke a share. It lives until it expires. """ payload = self._create_payload( - activity_id=activity_id, task_id=task_id, expires_in=expires_in + prediction_id=prediction_id, target_id=target_id, expires_in=expires_in ) - # not retried: a replay would trip the one-share-per-activity guard and report a - # 409 for a share that actually succeeded + # not retried: a replay would trip the one-share-per-prediction guard and report + # a 409 for a share that actually succeeded data = self._transport.request("POST", _BASE, json=payload) return Share.model_validate(data) def create_for( self, source: ResultSource, - item: ActivityResultItem, + item: PredictionResultItem, *, expires_in: Optional[int] = None, ) -> Share: @@ -359,8 +361,8 @@ def create_for( Args: source: Either a `DetectionResult` from `GuardClient.analyze` or an - `ActivityDetail` from `activities.get()`. - item: The `ActivityResultItem` being shared. + `PredictionDetail` from `predictions.get()`. + item: The `PredictionResultItem` being shared. expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 applies. @@ -371,15 +373,15 @@ def create_for( GuardError: If the source is a local result or the item is not one of its results. Both checks happen before any request is sent. """ - activity_id = self._resolve_source(source, item) + prediction_id = self._resolve_source(source, item) return self.create( - activity_id=activity_id, task_id=item.task_id, expires_in=expires_in + prediction_id=prediction_id, target_id=item.target_id, expires_in=expires_in ) class AsyncShares(_SharesBase): """ - Asynchronous activity-share endpoints. + Asynchronous prediction-share endpoints. This class mirrors `Shares` method for method. See the synchronous methods for full argument details. @@ -488,23 +490,23 @@ async def get(self, share_id: IdLike) -> Share: Raises: GuardNotFoundError: If the share is unknown or not yours. """ - data = await self._transport.request("GET", f"{_BASE}{share_id}") + data = await self._transport.request("GET", f"{_BASE}/{share_id}") return Share.model_validate(data) async def create( self, *, - activity_id: IdLike, - task_id: IdLike, + prediction_id: IdLike, + target_id: IdLike, expires_in: Optional[int] = None, ) -> Share: """ - Create a public link to one task result. + Create a public link to one target result. Args: - activity_id: The activity to share. Must be your own and completed. - task_id: Which task result the link shows. It must appear in the activity - results or the API answers 400. + prediction_id: The prediction to share. Must be your own and completed. + target_id: Which target result the link shows. It must appear in the + prediction results or the API answers 400. expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 applies. @@ -514,12 +516,12 @@ async def create( Raises: GuardError: If `expires_in` is out of range. - GuardConflictError: If this activity has already been shared. - GuardNotFoundError: If the activity is unknown, not yours, or its media is + GuardConflictError: If this prediction has already been shared. + GuardNotFoundError: If the prediction is unknown, not yours, or its media is gone. """ payload = self._create_payload( - activity_id=activity_id, task_id=task_id, expires_in=expires_in + prediction_id=prediction_id, target_id=target_id, expires_in=expires_in ) # not retried: see the note on Shares.create data = await self._transport.request("POST", _BASE, json=payload) @@ -528,7 +530,7 @@ async def create( async def create_for( self, source: ResultSource, - item: ActivityResultItem, + item: PredictionResultItem, *, expires_in: Optional[int] = None, ) -> Share: @@ -537,8 +539,8 @@ async def create_for( Args: source: Either a `DetectionResult` from `GuardClient.analyze` or an - `ActivityDetail` from `activities.get()`. - item: The `ActivityResultItem` being shared. + `PredictionDetail` from `predictions.get()`. + item: The `PredictionResultItem` being shared. expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 applies. @@ -549,7 +551,7 @@ async def create_for( GuardError: If the source is a local result or the item is not one of its results. Both checks happen before any request is sent. """ - activity_id = self._resolve_source(source, item) + prediction_id = self._resolve_source(source, item) return await self.create( - activity_id=activity_id, task_id=item.task_id, expires_in=expires_in + prediction_id=prediction_id, target_id=item.target_id, expires_in=expires_in ) diff --git a/src/guard_client/spaces.py b/src/guard_client/spaces.py index e50d8b1..a14e63e 100644 --- a/src/guard_client/spaces.py +++ b/src/guard_client/spaces.py @@ -1,7 +1,7 @@ """ -Low-level bindings for the `/api/v1/spaces/` endpoints. +Low-level bindings for the `/api/v1/spaces` endpoints. -Spaces are the containers activities are created in. Listing them is how a caller +Spaces are the containers predictions are created in. Listing them is how a caller discovers the `space_id` that `GuardClient.analyze` needs. `Spaces` and `AsyncSpaces` mirror each other. All query construction lives on @@ -40,7 +40,7 @@ __all__ = ["AsyncSpaces", "Spaces"] #: The base URL path for the spaces endpoints. -_BASE = "/api/v1/spaces/" +_BASE = "/api/v1/spaces" #: Minimum length for a space name. NAME_MIN_LENGTH = 3 @@ -138,7 +138,7 @@ def _create_payload( is_public: bool, user_id: Optional[IdLike], organization_id: Optional[IdLike], - enabled_task_ids: Optional[Sequence[IdLike]], + enabled_target_ids: Optional[Sequence[IdLike]], dedicated_runner_ids: Optional[Sequence[IdLike]], ) -> Dict[str, Any]: """ @@ -154,7 +154,7 @@ def _create_payload( is_public: Whether everyone can see the space. user_id: The owning user. Mutually exclusive with `organization_id`. organization_id: The owning organization. - enabled_task_ids: The tasks to enable in the space. + enabled_target_ids: The targets to enable in the space. dedicated_runner_ids: Allowed for organization spaces only. Returns: @@ -200,9 +200,9 @@ def _create_payload( else: payload["organization_id"] = str(organization_id) - task_ids = id_list(enabled_task_ids, field="enabled_task_ids") - if task_ids is not None: - payload["enabled_task_ids"] = task_ids + target_ids = id_list(enabled_target_ids, field="enabled_target_ids") + if target_ids is not None: + payload["enabled_target_ids"] = target_ids runner_ids = id_list(dedicated_runner_ids, field="dedicated_runner_ids") if runner_ids: @@ -250,7 +250,7 @@ def create( is_public: bool = False, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - enabled_task_ids: Optional[Sequence[IdLike]] = None, + enabled_target_ids: Optional[Sequence[IdLike]] = None, dedicated_runner_ids: Optional[Sequence[IdLike]] = None, ) -> Space: """ @@ -269,8 +269,8 @@ def create( only. A public space cannot be created inside a private organization. user_id: The owning user. Mutually exclusive with `organization_id`. organization_id: The owning organization. - enabled_task_ids: Tasks to enable from `Tasks.list`. Duplicates are dropped - and the order is preserved. + enabled_target_ids: Targets to enable from `Targets.list`. Duplicates are + dropped and the order is preserved. dedicated_runner_ids: Allowed for organization spaces only. Rejected for user spaces. @@ -285,17 +285,18 @@ def create( GuardPaymentRequiredError: If there is no active subscription or the space limit is reached. GuardAuthError: If the predictor is not enabled in your active plan. - GuardNotFoundError: If a task, runner, user, or organization id is unknown. + GuardNotFoundError: If a target, runner, user, or organization id is + unknown. Examples: ```python predictor = client.predictors.list()[0] - tasks = client.tasks.list(predictor_id=predictor.id) + targets = client.targets.list(predictor_id=predictor.id) space = client.spaces.create( name="My Space", predictor_id=predictor.id, organization_id=ORG_ID, - enabled_task_ids=[t.id for t in tasks], + enabled_target_ids=[t.id for t in targets], ) ``` @@ -310,7 +311,7 @@ def create( is_public=is_public, user_id=user_id, organization_id=organization_id, - enabled_task_ids=enabled_task_ids, + enabled_target_ids=enabled_target_ids, dedicated_runner_ids=dedicated_runner_ids, ) # never retried: creating a space is not idempotent, so replaying a timed-out @@ -384,13 +385,13 @@ def get(self, space_id: IdLike) -> SpaceDetail: Returns: A `SpaceDetail` object. This carries `predictor_multiplier`, - `max_media_size`, and the full `enabled_tasks`, none of which appear on the - summary `Space` objects returned by `list`. + `max_media_size`, and the full `enabled_targets`, none of which appear on + the summary `Space` objects returned by `list`. Raises: GuardNotFoundError: If the space is unknown or you cannot see it. """ - data = self._transport.request("GET", f"{_BASE}{space_id}") + data = self._transport.request("GET", f"{_BASE}/{space_id}") return SpaceDetail.model_validate(data) def iter_all( @@ -484,7 +485,7 @@ async def create( is_public: bool = False, user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, - enabled_task_ids: Optional[Sequence[IdLike]] = None, + enabled_target_ids: Optional[Sequence[IdLike]] = None, dedicated_runner_ids: Optional[Sequence[IdLike]] = None, ) -> Space: """ @@ -497,7 +498,7 @@ async def create( is_public: Whether everyone can see the space. user_id: The owning user. Mutually exclusive with `organization_id`. organization_id: The owning organization. - enabled_task_ids: The tasks to enable in the space. + enabled_target_ids: The targets to enable in the space. dedicated_runner_ids: Allowed for organization spaces only. Returns: @@ -516,7 +517,7 @@ async def create( is_public=is_public, user_id=user_id, organization_id=organization_id, - enabled_task_ids=enabled_task_ids, + enabled_target_ids=enabled_target_ids, dedicated_runner_ids=dedicated_runner_ids, ) # never retried: see the note on Spaces.create @@ -579,12 +580,12 @@ async def get(self, space_id: IdLike) -> SpaceDetail: Returns: A `SpaceDetail` object carrying `predictor_multiplier` and the full - `enabled_tasks`. Review `Spaces.get` for more details. + `enabled_targets`. Review `Spaces.get` for more details. Raises: GuardNotFoundError: If the space is unknown or you cannot see it. """ - data = await self._transport.request("GET", f"{_BASE}{space_id}") + data = await self._transport.request("GET", f"{_BASE}/{space_id}") return SpaceDetail.model_validate(data) async def iter_all( diff --git a/src/guard_client/tasks.py b/src/guard_client/targets.py similarity index 70% rename from src/guard_client/tasks.py rename to src/guard_client/targets.py index 7a0e46a..279b7f8 100644 --- a/src/guard_client/tasks.py +++ b/src/guard_client/targets.py @@ -1,8 +1,8 @@ """ -Low-level bindings for the `/api/v1/tasks/` endpoints. +Low-level bindings for the `/api/v1/targets` endpoints. -A task is one detection a space can run, such as AI-generated, violence, and so on. -Listing them supplies the `enabled_task_ids` accepted by `Spaces.create`. +A target is one detection a space can run, such as AI-generated, violence, and so on. +Listing them supplies the `enabled_target_ids` accepted by `Spaces.create`. """ from __future__ import annotations @@ -17,16 +17,16 @@ reject_conflicting_owners, validate_pagination, ) -from .models import SortOrder, Task, TaskOrder, TaskPage +from .models import SortOrder, Target, TargetOrder, TargetPage from .transport import AsyncTransport, SyncTransport -__all__ = ["AsyncTasks", "Tasks"] +__all__ = ["AsyncTargets", "Targets"] -#: The base URL path for the tasks endpoints. -_BASE = "/api/v1/tasks/" +#: The base URL path for the targets endpoints. +_BASE = "/api/v1/targets" -class _TasksBase: +class _TargetsBase: """ Query and payload construction with no network I/O. @@ -40,7 +40,7 @@ def _list_params( user_id: Optional[IdLike], organization_id: Optional[IdLike], predictor_id: Optional[IdLike], - sort_by: Optional[Union[TaskOrder, str]], + sort_by: Optional[Union[TargetOrder, str]], sort_order: Optional[Union[SortOrder, str]], skip: int, limit: int, @@ -52,9 +52,9 @@ def _list_params( mirror `list`, but none are optional here. Args: - user_id: Only tasks available to this user. - organization_id: Only tasks available to this organization. - predictor_id: Only tasks supported by this predictor. + user_id: Only targets available to this user. + organization_id: Only targets available to this organization. + predictor_id: Only targets supported by this predictor. sort_by: The field to sort the results by. sort_order: The direction to sort the results. skip: Offset of the results. Must be 0 or greater. @@ -76,13 +76,13 @@ def _list_params( organization_id=organization_id, predictor_id=predictor_id, ) - add_sort(params, sort_by, sort_order, TaskOrder) + add_sort(params, sort_by, sort_order, TargetOrder) return params -class Tasks(_TasksBase): +class Targets(_TargetsBase): """ - Synchronous task endpoints. + Synchronous target endpoints. This class is accessed through the client rather than being constructed directly, and it shares the client connection pool. @@ -103,19 +103,19 @@ def list( user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, predictor_id: Optional[IdLike] = None, - sort_by: Optional[Union[TaskOrder, str]] = None, + sort_by: Optional[Union[TargetOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, limit: int = MAX_LIMIT, - ) -> TaskPage: + ) -> TargetPage: """ - List the available detection tasks. + List the available detection targets. Args: - user_id: Only tasks available to this user. - organization_id: Only tasks available to this organization. - predictor_id: Only tasks the given predictor supports. This is the usual - filter when picking `enabled_task_ids` for a new space. + user_id: Only targets available to this user. + organization_id: Only targets available to this organization. + predictor_id: Only targets the given predictor supports. This is the usual + filter when picking `enabled_target_ids` for a new space. sort_by: Valid options include `"name"` or `"created_at"`. Server default: `"name"`. sort_order: `"asc"` or `"desc"`. Server default: `"asc"`. @@ -123,7 +123,7 @@ def list( limit: Page size, 1-100. Returns: - A `TaskPage`. You can iterate it like a list or read `.count`. + A `TargetPage`. You can iterate it like a list or read `.count`. Raises: GuardError: If a filter value is invalid or both owner filters were given. @@ -138,7 +138,7 @@ def list( limit=limit, ) data = self._transport.request("GET", _BASE, params=params) - return TaskPage.model_validate(data) + return TargetPage.model_validate(data) def iter_all( self, @@ -146,23 +146,23 @@ def iter_all( user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, predictor_id: Optional[IdLike] = None, - sort_by: Optional[Union[TaskOrder, str]] = None, + sort_by: Optional[Union[TargetOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, - ) -> Iterator[Task]: + ) -> Iterator[Target]: """ - Yield every matching task by fetching pages as needed. + Yield every matching target by fetching pages as needed. Args: - user_id: Only tasks available to this user. - organization_id: Only tasks available to this organization. - predictor_id: Only tasks supported by this predictor. + user_id: Only targets available to this user. + organization_id: Only targets available to this organization. + predictor_id: Only targets supported by this predictor. sort_by: The field to sort the results by. sort_order: The direction to sort the results. page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. Yields: - Each matching task with the oldest page first. + Each matching target with the oldest page first. Note: Pages are fetched lazily. Breaking out of the loop early stops the requests @@ -186,11 +186,11 @@ def iter_all( return -class AsyncTasks(_TasksBase): +class AsyncTargets(_TargetsBase): """ - Asynchronous task endpoints. + Asynchronous target endpoints. - This class mirrors `Tasks` method for method. See the synchronous methods for full + This class mirrors `Targets` method for method. See the synchronous methods for full argument details. """ @@ -209,25 +209,25 @@ async def list( user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, predictor_id: Optional[IdLike] = None, - sort_by: Optional[Union[TaskOrder, str]] = None, + sort_by: Optional[Union[TargetOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, skip: int = 0, limit: int = MAX_LIMIT, - ) -> TaskPage: + ) -> TargetPage: """ - List the tasks matching the given filters. + List the targets matching the given filters. Args: - user_id: Only tasks available to this user. - organization_id: Only tasks available to this organization. - predictor_id: Only tasks supported by this predictor. + user_id: Only targets available to this user. + organization_id: Only targets available to this organization. + predictor_id: Only targets supported by this predictor. sort_by: The field to sort the results by. sort_order: The direction to sort the results. skip: Offset. 0 or greater. limit: Page size, 1-100. Returns: - A `TaskPage`. Review `Tasks.list` for full filter details. + A `TargetPage`. Review `Targets.list` for full filter details. Raises: GuardError: If a filter value is invalid or both owner filters were given. @@ -242,7 +242,7 @@ async def list( limit=limit, ) data = await self._transport.request("GET", _BASE, params=params) - return TaskPage.model_validate(data) + return TargetPage.model_validate(data) async def iter_all( self, @@ -250,23 +250,23 @@ async def iter_all( user_id: Optional[IdLike] = None, organization_id: Optional[IdLike] = None, predictor_id: Optional[IdLike] = None, - sort_by: Optional[Union[TaskOrder, str]] = None, + sort_by: Optional[Union[TargetOrder, str]] = None, sort_order: Optional[Union[SortOrder, str]] = None, page_size: int = MAX_LIMIT, - ) -> AsyncIterator[Task]: + ) -> AsyncIterator[Target]: """ - Yield every matching task by fetching pages as needed. + Yield every matching target by fetching pages as needed. Args: - user_id: Only tasks available to this user. - organization_id: Only tasks available to this organization. - predictor_id: Only tasks supported by this predictor. + user_id: Only targets available to this user. + organization_id: Only targets available to this organization. + predictor_id: Only targets supported by this predictor. sort_by: The field to sort the results by. sort_order: The direction to sort the results. page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. Yields: - Each matching task. Review `Tasks.iter_all` for more context. + Each matching target. Review `Targets.iter_all` for more context. """ skip = 0 while True: @@ -279,8 +279,8 @@ async def iter_all( skip=skip, limit=page_size, ) - for task in page.data: - yield task + for target in page.data: + yield target skip += len(page.data) if not page.data or len(page.data) < page_size or skip >= page.count: diff --git a/src/guard_client/tokens.py b/src/guard_client/tokens.py index 0f45850..c3f7cdf 100644 --- a/src/guard_client/tokens.py +++ b/src/guard_client/tokens.py @@ -1,5 +1,5 @@ """ -Estimates what an activity will cost before creating it. +Estimates what a prediction will cost before creating it. The calculation comes down to the following formula: `tokens = frames x resolution_cost x model_multiplier` @@ -32,8 +32,8 @@ Warning: This is an estimate. The API currently reserves only the minimum possible cost when - an activity is created, and the authoritative figure is `payed_tokens` on the - finished activity. Expect the two to differ. + a prediction is created, and the authoritative figure is `payed_tokens` on the + finished prediction. Expect the two to differ. Note: There are two deliberate consequences of pricing by the long side. An ultrawide @@ -78,7 +78,7 @@ class TokenEstimate(BaseModel): """ - The projected cost of one activity, along with the inputs that produced it. + The projected cost of one prediction, along with the inputs that produced it. Carrying the breakdown alongside the total lets a caller explain the number rather than just report it. diff --git a/src/guard_client/transport.py b/src/guard_client/transport.py index 88f0966..2c4ff8e 100644 --- a/src/guard_client/transport.py +++ b/src/guard_client/transport.py @@ -36,8 +36,8 @@ DEFAULT_BASE_URL = "https://api.elhio.com" #: Methods replayed by default. Individual calls override this with `retry`. -#: `POST /activities/` opts in because a duplicate create only leaves an unused -#: activity, while `DELETE /runners/{id}` opts out because it tears down a live +#: `POST /predictions/` opts in because a duplicate create only leaves an unused +#: prediction, while `DELETE /runners/{id}` opts out because it tears down a live #: deployment. _IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) diff --git a/tests/conftest.py b/tests/conftest.py index eb188c7..1edb48d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,8 +25,8 @@ BASE_URL = "https://api.test.invalid" API_KEY = "test-key" SPACE_ID = UUID("11111111-1111-1111-1111-111111111111") -ACTIVITY_ID = UUID("22222222-2222-2222-2222-222222222222") -TASK_ID = UUID("33333333-3333-3333-3333-333333333333") +PREDICTION_ID = UUID("22222222-2222-2222-2222-222222222222") +TARGET_ID = UUID("33333333-3333-3333-3333-333333333333") ORG_ID = UUID("44444444-4444-4444-4444-444444444444") PREDICTOR_ID = UUID("55555555-5555-5555-5555-555555555555") USER_ID = UUID("77777777-7777-7777-7777-777777777777") @@ -68,16 +68,18 @@ def _now() -> str: def create_response(**overrides: Any) -> Dict[str, Any]: - """Build a mock response for an activity creation request.""" + """Build a mock response for a prediction creation request.""" payload: Dict[str, Any] = { - "id": str(ACTIVITY_ID), + "id": str(PREDICTION_ID), "status": "pending_upload", "created_at": _now(), "space_id": str(SPACE_ID), + "lab_id": None, "user_id": None, "account_id": None, "guest_id": None, "space_name": "Test Space", + "lab_name": None, "user_name": None, "account_name": None, "media_type": "image/png", @@ -95,9 +97,9 @@ def create_response(**overrides: Any) -> Dict[str, Any]: def status_response(status: str = "completed", **overrides: Any) -> Dict[str, Any]: - """Build a mock status response for an activity.""" + """Build a mock status response for a prediction.""" payload: Dict[str, Any] = { - "id": str(ACTIVITY_ID), + "id": str(PREDICTION_ID), "status": status, "user_id": None, "account_id": None, @@ -112,11 +114,11 @@ def detail_response( results: Optional[List[Dict[str, Any]]] = None, **overrides: Any, ) -> Dict[str, Any]: - """Build a mock detailed response for an activity.""" + """Build a mock detailed response for a prediction.""" if results is None: results = [ { - "task_id": str(TASK_ID), + "target_id": str(TARGET_ID), "score": 87, "label": "Deepfake", "description": "Likely synthetic", @@ -124,11 +126,12 @@ def detail_response( } ] payload: Dict[str, Any] = { - "id": str(ACTIVITY_ID), + "id": str(PREDICTION_ID), "status": status, "created_at": _now(), "updated_at": _now(), "space_id": str(SPACE_ID), + "lab_id": None, "predictor_id": str(uuid4()), "runner_id": None, "user_id": None, @@ -139,6 +142,7 @@ def detail_response( "payed_tokens": 1, "result_payload": {"results": results}, "space_name": "Test Space", + "lab_name": None, "predictor_name": "default", "runner_name": None, "user_name": None, @@ -167,7 +171,7 @@ def space_response(**overrides: Any) -> Dict[str, Any]: "predictor_id": str(PREDICTOR_ID), "predictor_name": "default", "enabled_media": ["image", "video"], - "enabled_task_names": ["Deepfake", "Violence"], + "enabled_target_names": ["Deepfake", "Violence"], } payload.update(overrides) return payload @@ -192,16 +196,16 @@ def predictor_response(**overrides: Any) -> Dict[str, Any]: "slug": "default-predictor", "url_id": "pred123", "supported_media": ["image", "video"], - "supported_task_ids": [str(TASK_ID)], + "supported_target_ids": [str(TARGET_ID)], } payload.update(overrides) return payload -def task_response(**overrides: Any) -> Dict[str, Any]: - """Build a mock response for a task.""" +def target_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a target.""" payload: Dict[str, Any] = { - "id": str(TASK_ID), + "id": str(TARGET_ID), "status": "active", "name": "Deepfake", "description": "Detects synthetic media", @@ -216,8 +220,8 @@ def reaction_response(**overrides: Any) -> Dict[str, Any]: payload: Dict[str, Any] = { "id": str(REACTION_ID), "created_at": _now(), - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "is_positive": True, "key_value": None, "description": None, @@ -231,6 +235,7 @@ def runner_response(**overrides: Any) -> Dict[str, Any]: payload: Dict[str, Any] = { "id": str(RUNNER_ID), "status": "running", + "deployment": "managed", "created_at": _now(), "terminated_at": None, "name": "runner-1", @@ -251,15 +256,15 @@ def share_response(**overrides: Any) -> Dict[str, Any]: "id": str(SHARE_ID), "created_at": _now(), "expired_at": (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "expires_in": 7, "share_url": "https://elhio.com/s/abc123", "media_url": "https://cdn.elhio.com/media/abc.jpg", - "task_name": "Deepfake", + "target_name": "Deepfake", "space_name": "Test Space", "result": { - "task_id": str(TASK_ID), + "target_id": str(TARGET_ID), "score": 87, "label": "Deepfake", "description": "Likely synthetic", @@ -291,9 +296,9 @@ def space_detail_response(**overrides: Any) -> Dict[str, Any]: "predictor_multiplier": 3, "max_media_size": 52428800, "enabled_media": ["image", "video"], - "enabled_tasks": [task_response()], - "task_thresholds": [ - {"task_id": str(TASK_ID), "blur_threshold": 50, "hide_threshold": 80} + "enabled_targets": [target_response()], + "target_thresholds": [ + {"target_id": str(TARGET_ID), "blur_threshold": 50, "hide_threshold": 80} ], } payload.update(overrides) @@ -308,7 +313,7 @@ def page_response( def confirm_response(**overrides: Any) -> Dict[str, Any]: - """Build a mock response for confirming an activity.""" + """Build a mock response for confirming a prediction.""" payload = create_response() payload.pop("upload_data") payload["status"] = "processing" @@ -325,7 +330,7 @@ def isolate_env(tmp_path_factory, monkeypatch): * exported ``GUARD_*`` variables in the shell running pytest, and * a real ``.env`` in the repo — ``find_dotenv`` walks *up* from the cwd, so once a - developer creates one for ``scripts/create_activity.py`` it would otherwise leak + developer creates one for ``scripts/create_prediction.py`` it would otherwise leak into the suite. Tests that want a ``.env`` write one into the cwd this provides. diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 71ca09d..52a8ba9 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -4,25 +4,29 @@ from __future__ import annotations +import json + import httpx import pytest import respx from guard_client import ( - ActivityFailedError, AsyncGuardClient, Engine, GuardClient, GuardError, + MediaType, + PredictionFailedError, UnsupportedMediaTypeError, ) +from guard_client.probe import MediaInfo from .conftest import ( - ACTIVITY_ID, API_KEY, BASE_URL, + PREDICTION_ID, SPACE_ID, - TASK_ID, + TARGET_ID, UPLOAD_URL, confirm_response, create_response, @@ -31,34 +35,34 @@ status_response, ) -ACTIVITIES_URL = f"{BASE_URL}/api/v1/activities/" +PREDICTIONS_URL = f"{BASE_URL}/api/v1/predictions" @pytest.fixture(autouse=True) def no_sleep(monkeypatch): """Ensure polling tests do not actually wait during execution.""" - monkeypatch.setattr("guard_client.activities.time.sleep", lambda _: None) + monkeypatch.setattr("guard_client.predictions.time.sleep", lambda _: None) async def _async_sleep(_): return None - monkeypatch.setattr("guard_client.activities.asyncio.sleep", _async_sleep) + monkeypatch.setattr("guard_client.predictions.asyncio.sleep", _async_sleep) def mock_full_lifecycle(status: str = "completed"): """Wire up every route the happy path touches and return the route objects.""" return { - "create": respx.post(ACTIVITIES_URL).mock( + "create": respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ), "upload": respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)), - "confirm": respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + "confirm": respx.post(f"{PREDICTIONS_URL}/{PREDICTION_ID}/confirm").mock( return_value=httpx.Response(200, json=confirm_response()) ), - "status": respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + "status": respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response(status)) ), - "detail": respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + "detail": respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}").mock( return_value=httpx.Response(200, json=detail_response(status)) ), } @@ -75,10 +79,10 @@ def test_analyze_runs_full_lifecycle(client, png): assert route.called, f"{name} was never called" assert result.engine is Engine.CLOUD - assert result.activity_id == ACTIVITY_ID + assert result.prediction_id == PREDICTION_ID assert result.max_score == 87 assert result.results[0].label == "Deepfake" - assert result.score_for(TASK_ID) == 87 + assert result.score_for(TARGET_ID) == 87 @respx.mock @@ -99,7 +103,7 @@ def test_analyze_accepts_raw_bytes(client): result = client.analyze(png_bytes()) - assert result.activity_id == ACTIVITY_ID + assert result.prediction_id == PREDICTION_ID @respx.mock @@ -110,7 +114,7 @@ def test_analyze_accepts_file_object(client, png): with open(png, "rb") as handle: result = client.analyze(handle) - assert result.activity_id == ACTIVITY_ID + assert result.prediction_id == PREDICTION_ID @respx.mock @@ -125,6 +129,70 @@ def test_analyze_sends_detected_type_and_size(client, png): assert f'"media_size":{len(png_bytes())}' in body +def create_body(routes): + """The JSON body of the create request.""" + return json.loads(routes["create"].calls.last.request.read()) + + +@respx.mock +def test_analyze_declares_the_probed_dimensions(client, png): + """Ensure an image's dimensions are declared, and no duration for a still.""" + routes = mock_full_lifecycle() + + client.analyze(png) + + body = create_body(routes) + assert (body["width"], body["height"]) == (1, 1) + assert "duration_seconds" not in body + + +@respx.mock +def test_analyze_declares_a_videos_duration(client, monkeypatch): + """Verify a video declares its duration along with its dimensions.""" + monkeypatch.setattr( + "guard_client.client.probe_media", + lambda *_, **__: MediaInfo( + media_type=MediaType.MP4, + width=1280, + height=720, + duration_seconds=4.5, + frames=5, + ), + ) + routes = mock_full_lifecycle() + + client.analyze(b"\x00" * 64, media_type="video/mp4") + + body = create_body(routes) + assert (body["width"], body["height"], body["duration_seconds"]) == (1280, 720, 4.5) + + +@respx.mock +def test_analyze_without_readable_dimensions_still_runs(client, png, monkeypatch): + """Ensure media the probe cannot read is analyzed without declared dimensions.""" + + def unreadable(*_, **__): + raise GuardError("cannot read the header") + + monkeypatch.setattr("guard_client.client.probe_media", unreadable) + routes = mock_full_lifecycle() + + result = client.analyze(png) + + assert not {"width", "height", "duration_seconds"} & create_body(routes).keys() + assert result.prediction_id == PREDICTION_ID + + +@respx.mock +async def test_async_analyze_declares_the_probed_dimensions(async_client, png): + """Verify the async client declares dimensions as the sync one does.""" + routes = mock_full_lifecycle() + + await async_client.analyze(png) + + assert (create_body(routes)["width"], create_body(routes)["height"]) == (1, 1) + + @respx.mock def test_analyze_upload_is_unauthenticated(client, png): """Verify the presigned S3 POST request does not carry the API bearer token.""" @@ -151,11 +219,11 @@ def test_analyze_orders_multipart_fields_before_file(client, png): @respx.mock -def test_analyze_raises_on_failed_activity(client, png): - """Verify an error is raised if the activity ends in a failed state.""" +def test_analyze_raises_on_failed_prediction(client, png): + """Verify an error is raised if the prediction ends in a failed state.""" mock_full_lifecycle(status="failed") - with pytest.raises(ActivityFailedError) as exc_info: + with pytest.raises(PredictionFailedError) as exc_info: client.analyze(png) assert exc_info.value.status == "failed" @@ -164,17 +232,17 @@ def test_analyze_raises_on_failed_activity(client, png): @respx.mock def test_analyze_handles_empty_results(client, png): """Ensure analyze handles missing result payloads gracefully.""" - respx.post(ACTIVITIES_URL).mock( + respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) - respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + respx.post(f"{PREDICTIONS_URL}/{PREDICTION_ID}/confirm").mock( return_value=httpx.Response(200, json=confirm_response()) ) - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response("completed")) ) - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}").mock( return_value=httpx.Response(200, json=detail_response(result_payload=None)) ) @@ -182,13 +250,13 @@ def test_analyze_handles_empty_results(client, png): assert result.results == [] assert result.max_score == 0 - assert result.score_for(TASK_ID) is None + assert result.score_for(TARGET_ID) is None @respx.mock def test_analyze_rejects_unsupported_media_before_any_call(client, tmp_path): """Verify unsupported media types are rejected locally before network calls.""" - route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + route = respx.post(PREDICTIONS_URL).mock(return_value=httpx.Response(200)) bad = tmp_path / "doc.pdf" bad.write_bytes(b"%PDF-1.4 nope") @@ -253,16 +321,16 @@ async def test_async_analyze_runs_full_lifecycle(async_client, png): assert route.called, f"{name} was never called" assert result.engine is Engine.CLOUD - assert result.activity_id == ACTIVITY_ID + assert result.prediction_id == PREDICTION_ID assert result.max_score == 87 @respx.mock async def test_async_analyze_raises_on_canceled(async_client, png): - """Ensure the async client raises an error if an activity is canceled.""" + """Ensure the async client raises an error if a prediction is canceled.""" mock_full_lifecycle(status="canceled") - with pytest.raises(ActivityFailedError) as exc_info: + with pytest.raises(PredictionFailedError) as exc_info: await async_client.analyze(png) assert exc_info.value.status == "canceled" diff --git a/tests/test_auth.py b/tests/test_auth.py index 3165069..5bed0ba 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -17,8 +17,8 @@ from .conftest import BASE_URL, SPACE_ID, UPLOAD_URL, png_bytes -SPACES_URL = f"{BASE_URL}/spaces/" -ACTIVITIES_URL = f"{BASE_URL}/activities/" +SPACES_URL = f"{BASE_URL}/api/v1/spaces" +PREDICTIONS_URL = f"{BASE_URL}/api/v1/predictions" @pytest.fixture @@ -58,7 +58,7 @@ def test_resource_call_without_key_raises(keyless): @respx.mock def test_per_call_cloud_override_without_key_raises(keyless): """Verify the per-call cloud engine override does not escape the API key guard.""" - route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200, json={})) + route = respx.post(PREDICTIONS_URL).mock(return_value=httpx.Response(200, json={})) with pytest.raises(GuardError, match="API key is required"): keyless.analyze(png_bytes(), engine="cloud") @@ -84,7 +84,7 @@ async def test_async_per_call_cloud_override_without_key_raises(async_keyless): """ Verify the async per-call cloud engine override does not escape the API key guard. """ - route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200, json={})) + route = respx.post(PREDICTIONS_URL).mock(return_value=httpx.Response(200, json={})) with pytest.raises(GuardError, match="API key is required"): await async_keyless.analyze(png_bytes(), engine="cloud") @@ -97,7 +97,7 @@ def test_error_names_every_way_to_supply_a_key(keyless): Check that the error message lists all valid methods to supply an API key. """ with pytest.raises(GuardError) as excinfo: - keyless.tasks.list() + keyless.targets.list() message = str(excinfo.value) assert "api_key=" in message diff --git a/tests/test_contract.py b/tests/test_contract.py index f43bdf1..3e67c96 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -85,7 +85,7 @@ def test_labels_are_stable_across_calls() -> None: """ Ensure labels remain entirely stable across multiple calls. - Local task IDs are generated using a UUID5 hash of the label. Because + Local target IDs are generated using a UUID5 hash of the label. Because of this, silently changing a label would inadvertently change the ID. """ def labels(raw: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]: diff --git a/tests/test_display.py b/tests/test_display.py index f603afa..e9acaae 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -12,11 +12,11 @@ import respx from guard_client import ( - ActivityResultItem, DetectionResult, Engine, GuardError, MediaType, + PredictionResultItem, Share, UnsupportedMediaTypeError, load_media, @@ -25,7 +25,7 @@ ) from guard_client import display as display_module -from .conftest import TASK_ID, png_bytes, share_response +from .conftest import TARGET_ID, png_bytes, share_response MEDIA_URL = "https://s3.test.invalid/app/results/solution.png" @@ -57,10 +57,10 @@ def displayed(monkeypatch): return rendered -def result_item(media_url=MEDIA_URL) -> ActivityResultItem: - """Create a mock ActivityResultItem for testing purposes.""" - return ActivityResultItem( - task_id=TASK_ID, score=87, label="Deepfake", media_url=media_url +def result_item(media_url=MEDIA_URL) -> PredictionResultItem: + """Create a mock PredictionResultItem for testing purposes.""" + return PredictionResultItem( + target_id=TARGET_ID, score=87, label="Deepfake", media_url=media_url ) @@ -102,7 +102,7 @@ def test_load_media_from_url(): @respx.mock def test_load_media_from_result_item(): - """Verify load_media successfully extracts media from an ActivityResultItem.""" + """Verify load_media successfully extracts media from a PredictionResultItem.""" respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) data, _, _ = load_media(result_item()) @@ -234,11 +234,11 @@ def test_show_detection_result_renders_each_item(displayed, viewer): result = DetectionResult( engine=Engine.CLOUD, results=[ - ActivityResultItem( - task_id=TASK_ID, score=1, label="A", media_url=MEDIA_URL + PredictionResultItem( + target_id=TARGET_ID, score=1, label="A", media_url=MEDIA_URL ), - ActivityResultItem( - task_id=TASK_ID, score=2, label="B", media_url=MEDIA_URL + PredictionResultItem( + target_id=TARGET_ID, score=2, label="B", media_url=MEDIA_URL ), ], ) @@ -254,7 +254,7 @@ def test_show_detection_result_skips_items_without_media(displayed, capsys): """Verify show() safely skips results missing a media_url and prints a warning.""" result = DetectionResult( engine=Engine.LOCAL, - results=[ActivityResultItem(task_id=TASK_ID, score=1, label="A")], + results=[PredictionResultItem(target_id=TARGET_ID, score=1, label="A")], ) show(result) @@ -397,7 +397,7 @@ def test_save_overwrites_by_default(tmp_path, png): @respx.mock def test_save_a_result_item(tmp_path, viewer): - """Verify save() properly downloads and writes an ActivityResultItem.""" + """Verify save() properly downloads and writes a PredictionResultItem.""" respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) target = save(result_item(), tmp_path / "solution.png") diff --git a/tests/test_estimate.py b/tests/test_estimate.py index 90ad84c..d7793c1 100644 --- a/tests/test_estimate.py +++ b/tests/test_estimate.py @@ -66,7 +66,7 @@ def test_per_call_space_id_overrides_the_default(client): @respx.mock def test_missing_multiplier_and_space_raises_before_any_call(): """ - Ensure an error is raised before network activity if both multiplier and space are + Ensure an error is raised before network prediction if both multiplier and space are missing. """ route = respx.get(SPACE_URL).mock(return_value=httpx.Response(200)) diff --git a/tests/test_local.py b/tests/test_local.py index d80a534..a25ffd0 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -126,7 +126,7 @@ def test_analyze_returns_unified_result(): result = runner.analyze(png_bytes()) assert result.engine is Engine.LOCAL - assert result.activity_id is None # local runs create no activity + assert result.prediction_id is None # local runs create no prediction assert len(result.results) == 1 assert result.results[0].label == "safe" assert result.results[0].score == 87 @@ -193,7 +193,7 @@ def test_client_routes_to_local_engine(): result = client.analyze(png_bytes()) assert result.engine is Engine.LOCAL - assert result.activity_id is None + assert result.prediction_id is None def test_per_call_engine_override_to_local(): @@ -357,25 +357,32 @@ def test_adapt_handles_list_of_entries(): assert [i.score for i in items] == [10, 90] -def test_adapt_derives_stable_task_ids(): +def test_adapt_derives_stable_target_ids(): """ - Ensure derived local task IDs are consistent and reproducible so callers can key off - them. + Ensure derived local target IDs are consistent and reproducible so callers can key + off them. """ first = _adapt({"label": "deepfake", "score": 0.5}) second = _adapt({"label": "deepfake", "score": 0.9}) - assert first[0].task_id == second[0].task_id + assert first[0].target_id == second[0].target_id -def test_adapt_honours_supplied_task_id(): +def test_adapt_honours_supplied_target_id(): """ - Verify the adapter preserves an explicit task ID if one is supplied by the engine. + Verify the adapter preserves an explicit target ID if one is supplied by the engine. """ - task_id = "44444444-4444-4444-4444-444444444444" - items = _adapt({"label": "x", "score": 1, "task_id": task_id}) + target_id = "44444444-4444-4444-4444-444444444444" + items = _adapt({"label": "x", "score": 1, "target_id": target_id}) - assert str(items[0].task_id) == task_id + assert str(items[0].target_id) == target_id + + +def test_adapt_accepts_the_engines_older_task_id_key(): + target_id = "55555555-5555-5555-5555-555555555555" + items = _adapt({"label": "x", "score": 1, "task_id": target_id}) + + assert str(items[0].target_id) == target_id def test_adapt_tolerates_empty_and_junk(): diff --git a/tests/test_activities.py b/tests/test_predictions.py similarity index 55% rename from tests/test_activities.py rename to tests/test_predictions.py index 1397c8a..26126b3 100644 --- a/tests/test_activities.py +++ b/tests/test_predictions.py @@ -1,9 +1,10 @@ """ -Tests for the low-level activity bindings, sync and async. +Tests for the low-level prediction bindings, sync and async. """ from __future__ import annotations +import json from datetime import date, datetime, timedelta, timezone import httpx @@ -12,16 +13,16 @@ from guard_client import ( MAX_HISTORY, - ActivityFailedError, - ActivityStatus, GuardError, GuardTimeoutError, MediaType, + PredictionFailedError, + PredictionStatus, ) from .conftest import ( - ACTIVITY_ID, BASE_URL, + PREDICTION_ID, SPACE_ID, UPLOAD_URL, confirm_response, @@ -31,75 +32,132 @@ status_response, ) -ACTIVITIES_URL = f"{BASE_URL}/api/v1/activities/" +PREDICTIONS_URL = f"{BASE_URL}/api/v1/predictions" @pytest.fixture(autouse=True) def no_sleep(monkeypatch): """Polling tests should not actually wait.""" - monkeypatch.setattr("guard_client.activities.time.sleep", lambda _: None) + monkeypatch.setattr("guard_client.predictions.time.sleep", lambda _: None) async def _async_sleep(_): return None - monkeypatch.setattr("guard_client.activities.asyncio.sleep", _async_sleep) + monkeypatch.setattr("guard_client.predictions.asyncio.sleep", _async_sleep) @respx.mock def test_create_sends_expected_payload(client): - """Verify that creating an activity sends the correct JSON payload to the API.""" - route = respx.post(ACTIVITIES_URL).mock( + """Verify that creating a prediction sends the correct JSON payload to the API.""" + route = respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) - activity = client.activities.create(media_type=MediaType.PNG, media_size=1024) + prediction = client.predictions.create(media_type=MediaType.PNG, media_size=1024) body = route.calls.last.request.read() assert b'"space_id":"11111111-1111-1111-1111-111111111111"' in body.replace( b" ", b"" ) assert b'"media_type":"image/png"' in body.replace(b" ", b"") - assert activity.id == ACTIVITY_ID - assert activity.status is ActivityStatus.PENDING_UPLOAD - assert activity.upload_data.url == UPLOAD_URL + assert prediction.id == PREDICTION_ID + assert prediction.status is PredictionStatus.PENDING_UPLOAD + assert prediction.upload_data.url == UPLOAD_URL + + +@respx.mock +def test_create_sends_declared_dimensions(client): + """Verify the dimensions the API reserves tokens from go in the body.""" + route = respx.post(PREDICTIONS_URL).mock( + return_value=httpx.Response(201, json=create_response(media_type="video/mp4")) + ) + + client.predictions.create( + media_type=MediaType.MP4, + media_size=2048, + width=1920, + height=1080, + duration_seconds=12, + ) + + body = json.loads(route.calls.last.request.read()) + assert body["width"] == 1920 + assert body["height"] == 1080 + assert body["duration_seconds"] == 12.0 + + +@respx.mock +def test_create_omits_undeclared_dimensions(client): + """Ensure no dimension is sent unless the caller supplied it.""" + route = respx.post(PREDICTIONS_URL).mock( + return_value=httpx.Response(201, json=create_response()) + ) + + client.predictions.create(media_type=MediaType.PNG, media_size=1024) + + body = json.loads(route.calls.last.request.read()) + assert not {"width", "height", "duration_seconds"} & body.keys() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"width": 10}, "together"), + ({"height": 10}, "together"), + ({"width": 0, "height": 10}, "width"), + ({"width": 10, "height": -1}, "height"), + ({"width": 10.5, "height": 10}, "width"), + ({"width": True, "height": 10}, "width"), + ({"duration_seconds": -1}, "duration_seconds"), + ], +) +def test_create_rejects_invalid_dimensions(client, kwargs, message): + """Verify invalid dimensions are refused before any request is sent.""" + with respx.mock(assert_all_called=False) as router: + route = router.post(PREDICTIONS_URL) + with pytest.raises(GuardError, match=message): + client.predictions.create( + media_type=MediaType.PNG, media_size=1024, **kwargs + ) + assert not route.called @respx.mock def test_create_accepts_string_media_type(client): """Ensure that string MIME types are correctly parsed into MediaType enums.""" - respx.post(ACTIVITIES_URL).mock( + respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) - activity = client.activities.create(media_type="image/png", media_size=10) + prediction = client.predictions.create(media_type="image/png", media_size=10) - assert activity.media_type is MediaType.PNG + assert prediction.media_type is MediaType.PNG @respx.mock def test_create_includes_owner_ids(client): """Verify that provided owner IDs are included while empty ones are omitted.""" - route = respx.post(ACTIVITIES_URL).mock( + route = respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) - client.activities.create(media_type=MediaType.PNG, media_size=1, user_id=SPACE_ID) + client.predictions.create(media_type=MediaType.PNG, media_size=1, user_id=SPACE_ID) body = route.calls.last.request.read().decode() assert str(SPACE_ID) in body assert "account_id" not in body # omitted rather than sent as null - assert "guest_id" not in body # the client never creates guest-owned activities + assert "guest_id" not in body # the client never creates guest-owned predictions @respx.mock def test_create_overrides_default_space(client): """Ensure an explicitly provided space ID overrides the client default space.""" other = "99999999-9999-9999-9999-999999999999" - route = respx.post(ACTIVITIES_URL).mock( + route = respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) - client.activities.create(media_type=MediaType.PNG, media_size=1, space_id=other) + client.predictions.create(media_type=MediaType.PNG, media_size=1, space_id=other) assert other in route.calls.last.request.read().decode() @@ -111,7 +169,7 @@ def test_create_without_space_id_raises(): bare = GuardClient(api_key="k", base_url=BASE_URL) try: with pytest.raises(GuardError, match="space_id is required"): - bare.activities.create(media_type=MediaType.PNG, media_size=1) + bare.predictions.create(media_type=MediaType.PNG, media_size=1) finally: bare.close() @@ -120,9 +178,9 @@ def test_create_without_space_id_raises(): def test_upload_posts_fields_and_file(client): """Verify that the presigned upload fields precede the file bytes in the body.""" route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) - activity = _make_activity() + prediction = _make_prediction() - client.activities.upload(activity.upload_data, png_bytes()) + client.predictions.upload(prediction.upload_data, png_bytes()) body = route.calls.last.request.read() assert b'name="key"' in body @@ -134,16 +192,16 @@ def test_upload_posts_fields_and_file(client): @respx.mock def test_confirm(client): """ - Ensure that confirming an upload transitions the activity status to processing. + Ensure that confirming an upload transitions the prediction status to processing. """ - route = respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + route = respx.post(f"{PREDICTIONS_URL}/{PREDICTION_ID}/confirm").mock( return_value=httpx.Response(200, json=confirm_response()) ) - activity = client.activities.confirm(ACTIVITY_ID) + prediction = client.predictions.confirm(PREDICTION_ID) assert route.called - assert activity.status is ActivityStatus.PROCESSING + assert prediction.status is PredictionStatus.PROCESSING @respx.mock @@ -151,11 +209,11 @@ def test_confirm_sends_no_guest_header(client): """ The guest flow is gone so every request goes out as the authenticated identity. """ - route = respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + route = respx.post(f"{PREDICTIONS_URL}/{PREDICTION_ID}/confirm").mock( return_value=httpx.Response(200, json=confirm_response()) ) - client.activities.confirm(ACTIVITY_ID) + client.predictions.confirm(PREDICTION_ID) assert "x-guest-id" not in route.calls.last.request.headers @@ -163,29 +221,29 @@ def test_confirm_sends_no_guest_header(client): def test_confirm_rejects_guest_id(client): """Verify that passing the removed guest_id argument raises a TypeError.""" with pytest.raises(TypeError): - client.activities.confirm(ACTIVITY_ID, guest_id=SPACE_ID) + client.predictions.confirm(PREDICTION_ID, guest_id=SPACE_ID) @respx.mock def test_get_status(client): - """Ensure that polling an activity status returns the correct current state.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + """Ensure that polling a prediction status returns the correct current state.""" + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response("processing")) ) - status = client.activities.get_status(ACTIVITY_ID) + status = client.predictions.get_status(PREDICTION_ID) - assert status.status is ActivityStatus.PROCESSING + assert status.status is PredictionStatus.PROCESSING @respx.mock def test_get_returns_results(client): - """Verify that fetching an activity retrieves its full detailed results payload.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + """Verify that fetching a prediction retrieves its full detailed results payload.""" + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}").mock( return_value=httpx.Response(200, json=detail_response()) ) - detail = client.activities.get(ACTIVITY_ID) + detail = client.predictions.get(PREDICTION_ID) assert detail.result_payload is not None assert detail.result_payload.results[0].label == "Deepfake" @@ -194,14 +252,14 @@ def test_get_returns_results(client): @respx.mock def test_get_tolerates_missing_result(client): - """A still-processing activity has no result_payload yet.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + """A still-processing prediction has no result_payload yet.""" + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}").mock( return_value=httpx.Response( 200, json=detail_response(status="processing", result_payload=None) ) ) - detail = client.activities.get(ACTIVITY_ID) + detail = client.predictions.get(PREDICTION_ID) assert detail.result_payload is None @@ -209,26 +267,51 @@ def test_get_tolerates_missing_result(client): @respx.mock def test_list_unwraps_data_envelope(client): """Ensure the list endpoint correctly unwraps the paginated data envelope.""" - respx.get(ACTIVITIES_URL).mock( + respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response( 200, json={"data": [confirm_response()], "count": 1} ) ) - activities = client.activities.list(space_id=SPACE_ID) + predictions = client.predictions.list(space_id=SPACE_ID) + + assert len(predictions) == 1 + assert predictions[0].id == PREDICTION_ID + + +@respx.mock +def test_list_parses_a_labs_prediction(client): + """Ensure a prediction of a lab, which has no space, still parses.""" + lab_id = "77777777-7777-7777-7777-777777777777" + respx.get(PREDICTIONS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + confirm_response( + space_id=None, space_name=None, lab_id=lab_id, lab_name="Lab" + ) + ], + "count": 1, + }, + ) + ) + + (prediction,) = client.predictions.list() - assert len(activities) == 1 - assert activities[0].id == ACTIVITY_ID + assert prediction.space_id is None + assert str(prediction.lab_id) == lab_id + assert prediction.lab_name == "Lab" @respx.mock def test_list_passes_statuses(client): """Verify that multiple status filters are correctly sent in the query.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": []}) ) - client.activities.list(statuses=[ActivityStatus.COMPLETED, "failed"], limit=5) + client.predictions.list(statuses=[PredictionStatus.COMPLETED, "failed"], limit=5) params = route.calls.last.request.url.params assert params.get_list("statuses") == ["completed", "failed"] @@ -238,13 +321,13 @@ def test_list_passes_statuses(client): @respx.mock def test_list_returns_page_with_count(client): """Ensure the returned page object correctly exposes the total match count.""" - respx.get(ACTIVITIES_URL).mock( + respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response( 200, json={"data": [confirm_response()], "count": 17} ) ) - page = client.activities.list() + page = client.predictions.list() assert page.count == 17 assert len(page) == 1 @@ -254,11 +337,11 @@ def test_list_returns_page_with_count(client): @respx.mock def test_list_passes_sorting(client): """Verify that sort_by and sort_order parameters are properly sent to the API.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) - client.activities.list(sort_by="created_at", sort_order="asc") + client.predictions.list(sort_by="created_at", sort_order="asc") params = route.calls.last.request.url.params assert params["sort_by"] == "created_at" @@ -268,11 +351,11 @@ def test_list_passes_sorting(client): @respx.mock def test_list_passes_date_range(client): """Ensure that date ranges are correctly formatted as ISO-8601 strings.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) - client.activities.list( + client.predictions.list( start_date=datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc), end_date="2026-02-01T00:00:00Z", ) @@ -287,18 +370,18 @@ def test_list_accepts_both_owner_filters(client): """ Unlike /spaces/, this route applies both owner filters independently. - The read_activities endpoint has no mutual-exclusion check, so imposing one + The read_predictions endpoint has no mutual-exclusion check, so imposing one client-side would block a query the API actually serves. """ - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) - client.activities.list(user_id=SPACE_ID, organization_id=ACTIVITY_ID) + client.predictions.list(user_id=SPACE_ID, organization_id=PREDICTION_ID) params = route.calls.last.request.url.params assert params["user_id"] == str(SPACE_ID) - assert params["organization_id"] == str(ACTIVITY_ID) + assert params["organization_id"] == str(PREDICTION_ID) @pytest.mark.parametrize( @@ -315,10 +398,10 @@ def test_list_accepts_both_owner_filters(client): @respx.mock def test_list_rejects_start_date_beyond_retention(client, too_old): """The API keeps one year of history and returns 400 for anything older.""" - route = respx.get(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + route = respx.get(PREDICTIONS_URL).mock(return_value=httpx.Response(200)) with pytest.raises(GuardError, match="only 365 days of history"): - client.activities.list(start_date=too_old) + client.predictions.list(start_date=too_old) assert not route.called @@ -336,11 +419,11 @@ def test_list_rejects_start_date_beyond_retention(client, too_old): @respx.mock def test_list_allows_dates_within_retention(client, recent): """Verify that dates falling within the API retention window are accepted.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) - client.activities.list(start_date=recent) + client.predictions.list(start_date=recent) assert route.called @@ -348,12 +431,12 @@ def test_list_allows_dates_within_retention(client, recent): @respx.mock def test_retention_guard_has_clock_grace(client): """A boundary date must not be rejected by a client clock running slightly fast.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) # exactly one year back: the server's cutoff is later than ours, so it would accept - client.activities.list(start_date=datetime.now(timezone.utc) - MAX_HISTORY) + client.predictions.list(start_date=datetime.now(timezone.utc) - MAX_HISTORY) assert route.called @@ -361,11 +444,11 @@ def test_retention_guard_has_clock_grace(client): @respx.mock def test_end_date_is_not_retention_checked(client): """Only start_date has a retention rule since end_date is unconstrained.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json={"data": [], "count": 0}) ) - client.activities.list(end_date=datetime(2000, 1, 1, tzinfo=timezone.utc)) + client.predictions.list(end_date=datetime(2000, 1, 1, tzinfo=timezone.utc)) assert route.called @@ -373,10 +456,10 @@ def test_end_date_is_not_retention_checked(client): @respx.mock def test_list_rejects_invalid_sort(client): """Ensure that an invalid sort order raises an error before hitting the network.""" - route = respx.get(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + route = respx.get(PREDICTIONS_URL).mock(return_value=httpx.Response(200)) with pytest.raises(GuardError, match="Invalid sort_order"): - client.activities.list(sort_order="sideways") + client.predictions.list(sort_order="sideways") assert not route.called @@ -384,7 +467,7 @@ def test_list_rejects_invalid_sort(client): @respx.mock def test_iter_all_walks_pages(client): """Verify that iter_all correctly paginates through all available results.""" - route = respx.get(ACTIVITIES_URL).mock( + route = respx.get(PREDICTIONS_URL).mock( side_effect=[ httpx.Response( 200, json={"data": [confirm_response(), confirm_response()], "count": 3} @@ -393,14 +476,14 @@ def test_iter_all_walks_pages(client): ] ) - assert len(list(client.activities.iter_all(page_size=2))) == 3 + assert len(list(client.predictions.iter_all(page_size=2))) == 3 assert route.call_count == 2 @respx.mock def test_wait_until_done_polls_until_complete(client): """Ensure wait_until_done polls correctly until a completed status is returned.""" - route = respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + route = respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( side_effect=[ httpx.Response(200, json=status_response("pending_upload")), httpx.Response(200, json=status_response("processing")), @@ -408,58 +491,58 @@ def test_wait_until_done_polls_until_complete(client): ] ) - result = client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + result = client.predictions.wait_until_done(PREDICTION_ID, interval=0.01) - assert result.status is ActivityStatus.COMPLETED + assert result.status is PredictionStatus.COMPLETED assert route.call_count == 3 @pytest.mark.parametrize("terminal", ["failed", "canceled"]) @respx.mock def test_wait_until_done_raises_on_failure(client, terminal): - """Verify that wait_until_done raises an error if the activity fails or cancels.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + """Verify that wait_until_done raises if the prediction fails or is canceled.""" + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response(terminal)) ) - with pytest.raises(ActivityFailedError) as exc_info: - client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + with pytest.raises(PredictionFailedError) as exc_info: + client.predictions.wait_until_done(PREDICTION_ID, interval=0.01) assert exc_info.value.status == terminal - assert exc_info.value.activity_id == ACTIVITY_ID + assert exc_info.value.prediction_id == PREDICTION_ID @respx.mock def test_wait_until_done_times_out(client): """Ensure wait_until_done raises a timeout error if the polling deadline passes.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response("processing")) ) with pytest.raises(GuardTimeoutError) as exc_info: - client.activities.wait_until_done(ACTIVITY_ID, interval=10.0, timeout=0.0) + client.predictions.wait_until_done(PREDICTION_ID, interval=10.0, timeout=0.0) - assert exc_info.value.activity_id == ACTIVITY_ID + assert exc_info.value.prediction_id == PREDICTION_ID assert "processing" in str(exc_info.value) @respx.mock async def test_async_create_and_status(async_client): - """Verify that the async client can successfully create and poll an activity.""" - respx.post(ACTIVITIES_URL).mock( + """Verify that the async client can successfully create and poll a prediction.""" + respx.post(PREDICTIONS_URL).mock( return_value=httpx.Response(200, json=create_response()) ) - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response("completed")) ) - activity = await async_client.activities.create( + prediction = await async_client.predictions.create( media_type=MediaType.PNG, media_size=8 ) - status = await async_client.activities.get_status(activity.id) + status = await async_client.predictions.get_status(prediction.id) - assert activity.id == ACTIVITY_ID - assert status.status is ActivityStatus.COMPLETED + assert prediction.id == PREDICTION_ID + assert status.status is PredictionStatus.COMPLETED @respx.mock @@ -470,49 +553,49 @@ async def test_async_wait_until_done_uses_asyncio_sleep(async_client, monkeypatc async def _tracking_sleep(delay): calls.append(delay) - monkeypatch.setattr("guard_client.activities.asyncio.sleep", _tracking_sleep) + monkeypatch.setattr("guard_client.predictions.asyncio.sleep", _tracking_sleep) def _boom(_): raise AssertionError("async path must not call time.sleep") - monkeypatch.setattr("guard_client.activities.time.sleep", _boom) + monkeypatch.setattr("guard_client.predictions.time.sleep", _boom) - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( side_effect=[ httpx.Response(200, json=status_response("processing")), httpx.Response(200, json=status_response("completed")), ] ) - await async_client.activities.wait_until_done(ACTIVITY_ID, interval=0.25) + await async_client.predictions.wait_until_done(PREDICTION_ID, interval=0.25) assert calls == [0.25] @respx.mock async def test_async_wait_until_done_raises_on_failure(async_client): - """Ensure the async wait_until_done raises an error if the activity fails.""" - respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + """Ensure the async wait_until_done raises an error if the prediction fails.""" + respx.get(f"{PREDICTIONS_URL}/{PREDICTION_ID}/status").mock( return_value=httpx.Response(200, json=status_response("failed")) ) - with pytest.raises(ActivityFailedError): - await async_client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + with pytest.raises(PredictionFailedError): + await async_client.predictions.wait_until_done(PREDICTION_ID, interval=0.01) @respx.mock async def test_async_upload(async_client): """Verify that the async client successfully uploads media to the presigned URL.""" route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) - activity = _make_activity() + prediction = _make_prediction() - await async_client.activities.upload(activity.upload_data, png_bytes()) + await async_client.predictions.upload(prediction.upload_data, png_bytes()) assert route.called -def _make_activity(): - """Helper to build a valid ActivityCreateResponse for testing.""" - from guard_client import ActivityCreateResponse +def _make_prediction(): + """Helper to build a valid PredictionCreateResponse for testing.""" + from guard_client import PredictionCreateResponse - return ActivityCreateResponse.model_validate(create_response()) + return PredictionCreateResponse.model_validate(create_response()) diff --git a/tests/test_predictors.py b/tests/test_predictors.py index 526ee6d..a8a045e 100644 --- a/tests/test_predictors.py +++ b/tests/test_predictors.py @@ -14,12 +14,12 @@ BASE_URL, ORG_ID, PREDICTOR_ID, - TASK_ID, + TARGET_ID, page_response, predictor_response, ) -PREDICTORS_URL = f"{BASE_URL}/api/v1/predictors/" +PREDICTORS_URL = f"{BASE_URL}/api/v1/predictors" @respx.mock @@ -43,7 +43,7 @@ def test_list_parses_predictors(client): assert predictor.status is PredictorStatus.ACTIVE assert predictor.token_multiplier == 1 assert predictor.supported_media == [MediaCategory.IMAGE, MediaCategory.VIDEO] - assert predictor.supported_task_ids == [TASK_ID] + assert predictor.supported_target_ids == [TARGET_ID] @respx.mock @@ -58,7 +58,7 @@ def test_list_passes_filters(client): client.predictors.list( organization_id=ORG_ID, - supported_task_ids=[TASK_ID], + supported_target_ids=[TARGET_ID], sort_by="name", sort_order="desc", skip=5, @@ -67,7 +67,7 @@ def test_list_passes_filters(client): params = route.calls.last.request.url.params assert params["organization_id"] == str(ORG_ID) - assert params.get_list("supported_task_ids") == [str(TASK_ID)] + assert params.get_list("supported_target_ids") == [str(TARGET_ID)] assert params["sort_by"] == "name" assert params["sort_order"] == "desc" assert params["skip"] == "5" @@ -86,7 +86,7 @@ def test_list_omits_unset_filters(client): client.predictors.list() params = route.calls.last.request.url.params - for absent in ("user_id", "organization_id", "supported_task_ids", "sort_by"): + for absent in ("user_id", "organization_id", "supported_target_ids", "sort_by"): assert absent not in params @@ -101,7 +101,7 @@ def test_conflicting_owner_filters_raise_before_any_request(client): ) with pytest.raises(GuardError, match="both user_id and organization_id"): - client.predictors.list(user_id=TASK_ID, organization_id=ORG_ID) + client.predictors.list(user_id=TARGET_ID, organization_id=ORG_ID) assert not route.called diff --git a/tests/test_reactions.py b/tests/test_reactions.py index 96c3e00..07ba3f3 100644 --- a/tests/test_reactions.py +++ b/tests/test_reactions.py @@ -1,5 +1,5 @@ """ -Tests for the reactions resource: feedback on an activity result. +Tests for the reactions resource: feedback on a prediction result. """ from __future__ import annotations @@ -12,8 +12,6 @@ import respx from guard_client import ( - ActivityDetail, - ActivityResultItem, DetectionResult, Engine, GuardClient, @@ -21,20 +19,22 @@ GuardError, GuardNotFoundError, GuardServerError, + PredictionDetail, + PredictionResultItem, ) from .conftest import ( - ACTIVITY_ID, API_KEY, BASE_URL, + PREDICTION_ID, REACTION_ID, SPACE_ID, - TASK_ID, + TARGET_ID, detail_response, reaction_response, ) -REACTIONS_URL = f"{BASE_URL}/api/v1/reactions/" +REACTIONS_URL = f"{BASE_URL}/api/v1/reactions" def body_of(route): @@ -46,9 +46,9 @@ def cloud_result(**overrides): """Build a mock DetectionResult mimicking what analyze() returns from the cloud.""" payload = { "engine": Engine.CLOUD, - "activity_id": ACTIVITY_ID, + "prediction_id": PREDICTION_ID, "results": [ - ActivityResultItem(task_id=TASK_ID, score=87, label="Deepfake"), + PredictionResultItem(target_id=TARGET_ID, score=87, label="Deepfake"), ], } payload.update(overrides) @@ -63,12 +63,12 @@ def test_create_minimal(client): ) reaction = client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True ) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "is_positive": True, } assert reaction.id == REACTION_ID @@ -86,16 +86,16 @@ def test_create_with_every_field(client): ) reaction = client.reactions.create( - activity_id=ACTIVITY_ID, - task_id=TASK_ID, + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, is_positive=False, key_value=2, description="wrong", ) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "is_positive": False, "key_value": 2, "description": "wrong", @@ -111,7 +111,9 @@ def test_create_omits_unset_optionals(client): return_value=httpx.Response(200, json=reaction_response()) ) - client.reactions.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True) + client.reactions.create( + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True + ) body = body_of(route) assert "key_value" not in body @@ -129,8 +131,8 @@ def test_create_strips_description(client): ) client.reactions.create( - activity_id=ACTIVITY_ID, - task_id=TASK_ID, + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, is_positive=True, description=" padded ", ) @@ -146,7 +148,10 @@ def test_create_treats_blank_description_as_unset(client): ) client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True, description=" " + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, + is_positive=True, + description=" ", ) assert "description" not in body_of(route) @@ -161,7 +166,9 @@ def test_create_sends_no_guest_header(client): return_value=httpx.Response(200, json=reaction_response()) ) - client.reactions.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True) + client.reactions.create( + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True + ) assert "x-guest-id" not in route.calls.last.request.headers @@ -170,8 +177,8 @@ def test_create_rejects_guest_id(client): """Verify that passing the removed guest_id argument raises a type error.""" with pytest.raises(TypeError): client.reactions.create( - activity_id=ACTIVITY_ID, - task_id=TASK_ID, + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, is_positive=True, guest_id=SPACE_ID, ) @@ -187,7 +194,7 @@ def test_create_key_value_zero_is_sent(client): ) client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=False, key_value=0 + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=False, key_value=0 ) assert body_of(route)["key_value"] == 0 @@ -201,7 +208,7 @@ def test_create_rejects_non_bool_is_positive(client, bad): with pytest.raises(GuardError, match="Expected a boolean"): client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=bad + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=bad ) assert not route.called @@ -215,7 +222,10 @@ def test_create_rejects_non_int_key_value(client, bad): with pytest.raises(GuardError, match="Invalid key_value"): client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True, key_value=bad + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, + is_positive=True, + key_value=bad, ) assert not route.called @@ -230,8 +240,8 @@ def test_create_rejects_overlong_description(client): with pytest.raises(GuardError, match="at most 255 characters"): client.reactions.create( - activity_id=ACTIVITY_ID, - task_id=TASK_ID, + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, is_positive=True, description="d" * 256, ) @@ -247,8 +257,8 @@ def test_description_at_the_limit_is_accepted(client): ) client.reactions.create( - activity_id=ACTIVITY_ID, - task_id=TASK_ID, + prediction_id=PREDICTION_ID, + target_id=TARGET_ID, is_positive=True, description="d" * 255, ) @@ -269,41 +279,41 @@ def test_create_for_extracts_ids(client): ) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "is_positive": False, "key_value": 1, } @respx.mock -def test_create_for_from_activity_detail(client): - """Verify create_for works correctly using an ActivityDetail object.""" +def test_create_for_from_prediction_detail(client): + """Verify create_for works correctly using a PredictionDetail object.""" route = respx.post(REACTIONS_URL).mock( return_value=httpx.Response(200, json=reaction_response()) ) - detail = ActivityDetail.model_validate(detail_response()) + detail = PredictionDetail.model_validate(detail_response()) client.reactions.create_for( detail, detail.result_payload.results[0], is_positive=True ) body = body_of(route) - assert body["activity_id"] == str(ACTIVITY_ID) - assert body["task_id"] == str(TASK_ID) + assert body["prediction_id"] == str(PREDICTION_ID) + assert body["target_id"] == str(TARGET_ID) @respx.mock def test_create_for_rejects_foreign_item(client): """ - Ensure create_for rejects an ActivityResultItem that does not belong to the result + Ensure create_for rejects a PredictionResultItem that does not belong to the result object. """ route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) result = cloud_result() - stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + stranger = PredictionResultItem(target_id=uuid4(), score=1, label="Other") - with pytest.raises(GuardError, match="is not part of this activity's results"): + with pytest.raises(GuardError, match="is not part of this prediction's results"): client.reactions.create_for(result, stranger, is_positive=True) assert not route.called @@ -313,13 +323,13 @@ def test_create_for_rejects_foreign_item(client): def test_create_for_rejects_local_result(client): """ Ensure create_for raises an error for local engine results since they lack server - activity IDs. + prediction IDs. """ route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) local = DetectionResult( engine=Engine.LOCAL, - activity_id=None, - results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + prediction_id=None, + results=[PredictionResultItem(target_id=TARGET_ID, score=10, label="safe")], ) with pytest.raises(GuardError, match="local engine"): @@ -331,8 +341,8 @@ def test_create_for_rejects_local_result(client): @pytest.mark.parametrize( ("status", "expected", "detail"), [ - (409, GuardConflictError, "Cannot react to an activity result multiple times"), - (404, GuardNotFoundError, "Activity not found"), + (409, GuardConflictError, "Cannot react to a prediction result multiple times"), + (404, GuardNotFoundError, "Prediction not found"), ], ) @respx.mock @@ -344,7 +354,7 @@ def test_create_maps_server_errors(client, status, expected, detail): with pytest.raises(expected) as exc_info: client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True ) assert exc_info.value.status_code == status @@ -366,7 +376,7 @@ def test_create_is_never_retried(monkeypatch): try: with pytest.raises(GuardServerError): retrying.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True ) finally: retrying.close() @@ -382,7 +392,7 @@ async def test_async_create(async_client): ) reaction = await async_client.reactions.create( - activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + prediction_id=PREDICTION_ID, target_id=TARGET_ID, is_positive=True ) assert body_of(route)["is_positive"] is True @@ -402,7 +412,7 @@ async def test_async_create_for(async_client): ) body = body_of(route) - assert body["activity_id"] == str(ACTIVITY_ID) + assert body["prediction_id"] == str(PREDICTION_ID) assert body["description"] == "good" @@ -415,7 +425,7 @@ async def test_async_create_for_rejects_local_result(async_client): route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) local = DetectionResult( engine=Engine.LOCAL, - results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + results=[PredictionResultItem(target_id=TARGET_ID, score=10, label="safe")], ) with pytest.raises(GuardError, match="local engine"): diff --git a/tests/test_runners.py b/tests/test_runners.py index 8ecc3a2..2ffebd8 100644 --- a/tests/test_runners.py +++ b/tests/test_runners.py @@ -17,6 +17,8 @@ GuardNotFoundError, GuardPaymentRequiredError, GuardServerError, + Runner, + RunnerDeployment, RunnerOrder, RunnerStatus, ) @@ -32,7 +34,7 @@ runner_response, ) -RUNNERS_URL = f"{BASE_URL}/api/v1/runners/" +RUNNERS_URL = f"{BASE_URL}/api/v1/runners" OTHER_ORG = "99999999-9999-9999-9999-999999999999" @@ -93,7 +95,7 @@ def test_list_parses_terminated_runner(org_client): @respx.mock def test_get_runner(org_client): """Verify that fetching a single runner by ID returns the correct runner object.""" - route = respx.get(f"{RUNNERS_URL}{RUNNER_ID}").mock( + route = respx.get(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response(200, json=runner_response()) ) @@ -281,8 +283,43 @@ def test_create_minimal(org_client): "predictor_id": str(PREDICTOR_ID), "organization_id": str(ORG_ID), "is_default": False, + "deployment": "managed", } assert runner.status is RunnerStatus.PENDING + assert runner.deployment is RunnerDeployment.MANAGED + + +@pytest.mark.parametrize("deployment", [RunnerDeployment.EXTERNAL, "external"]) +@respx.mock +def test_create_sends_external_deployment(org_client, deployment): + """Ensure an external runner is requested as such, by member or by value.""" + route = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response( + 201, json=runner_response(status="pending", deployment="external") + ) + ) + + runner = org_client.runners.create(predictor_id=PREDICTOR_ID, deployment=deployment) + + assert body_of(route)["deployment"] == "external" + assert runner.deployment is RunnerDeployment.EXTERNAL + + +def test_create_rejects_unknown_deployment(org_client): + """Verify an unknown deployment is refused before any request is sent.""" + with respx.mock(assert_all_called=False) as router: + route = router.post(RUNNERS_URL) + with pytest.raises(GuardError, match="deployment"): + org_client.runners.create(predictor_id=PREDICTOR_ID, deployment="cloud") + assert not route.called + + +def test_runner_without_deployment_reads_as_managed(): + """Ensure a response from before the field existed still parses.""" + payload = runner_response() + payload.pop("deployment") + + assert Runner.model_validate(payload).deployment is RunnerDeployment.MANAGED @respx.mock @@ -356,6 +393,11 @@ def test_create_without_organization_raises(client): [ (402, GuardPaymentRequiredError, "Organization needs active subscription"), (402, GuardPaymentRequiredError, "Organization has reached limit of 3 runners"), + ( + 402, + GuardPaymentRequiredError, + "Organization's plan does not allow external runners", + ), (409, GuardConflictError, "A runner with this name already exists"), (404, GuardNotFoundError, "Predictor not found"), ], @@ -379,7 +421,7 @@ def test_create_maps_server_errors(org_client, status, expected, detail): @respx.mock def test_delete_returns_none(org_client): """Verify that successfully deleting a runner returns None.""" - route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + route = respx.delete(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response( 200, json={"message": "Runner deleted successfully"} ) @@ -396,7 +438,7 @@ def test_delete_is_never_retried(monkeypatch): actions. """ monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) - route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + route = respx.delete(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response(503, json={"detail": "unavailable"}) ) @@ -416,7 +458,7 @@ def test_delete_is_never_retried(monkeypatch): def test_delete_not_retried_on_connection_error(monkeypatch): """Ensure deletion requests are not retried even on connection errors.""" monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) - route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + route = respx.delete(f"{RUNNERS_URL}/{RUNNER_ID}").mock( side_effect=httpx.ConnectError("dropped") ) @@ -435,7 +477,7 @@ def test_delete_not_retried_on_connection_error(monkeypatch): @respx.mock def test_delete_maps_not_found(org_client): """Verify that a 404 response on deletion maps to a GuardNotFoundError.""" - respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + respx.delete(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response(404, json={"detail": "Runner not found"}) ) @@ -483,7 +525,7 @@ async def test_async_list_and_get(async_org_client): 200, json=page_response([runner_response()], count=1) ) ) - respx.get(f"{RUNNERS_URL}{RUNNER_ID}").mock( + respx.get(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response(200, json=runner_response()) ) @@ -500,7 +542,7 @@ async def test_async_create_and_delete(async_org_client): create = respx.post(RUNNERS_URL).mock( return_value=httpx.Response(200, json=runner_response(status="pending")) ) - delete = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + delete = respx.delete(f"{RUNNERS_URL}/{RUNNER_ID}").mock( return_value=httpx.Response(200, json={"message": "ok"}) ) diff --git a/tests/test_shares.py b/tests/test_shares.py index e661457..2722fc4 100644 --- a/tests/test_shares.py +++ b/tests/test_shares.py @@ -1,5 +1,5 @@ """ -Tests for the activity-shares resource: read and create. +Tests for the prediction-shares resource: read and create. """ from __future__ import annotations @@ -13,8 +13,6 @@ import respx from guard_client import ( - ActivityDetail, - ActivityResultItem, DetectionResult, Engine, GuardAPIError, @@ -23,24 +21,26 @@ GuardError, GuardNotFoundError, GuardServerError, + PredictionDetail, + PredictionResultItem, Share, ShareOrder, ) from .conftest import ( - ACTIVITY_ID, API_KEY, BASE_URL, ORG_ID, + PREDICTION_ID, SHARE_ID, - TASK_ID, + TARGET_ID, USER_ID, detail_response, page_response, share_response, ) -SHARES_URL = f"{BASE_URL}/api/v1/activities/shares/" +SHARES_URL = f"{BASE_URL}/api/v1/predictions/shares" def params_of(route): @@ -57,8 +57,10 @@ def cloud_result(**overrides): """Build a mock DetectionResult representing a completed cloud analysis.""" payload = { "engine": Engine.CLOUD, - "activity_id": ACTIVITY_ID, - "results": [ActivityResultItem(task_id=TASK_ID, score=87, label="Deepfake")], + "prediction_id": PREDICTION_ID, + "results": [ + PredictionResultItem(target_id=TARGET_ID, score=87, label="Deepfake") + ], } payload.update(overrides) return DetectionResult(**payload) @@ -82,9 +84,9 @@ def test_list_parses_shares_and_nested_result(client): share = page[0] assert share.id == SHARE_ID assert share.share_url == "https://elhio.com/s/abc123" - assert share.task_name == "Deepfake" + assert share.target_name == "Deepfake" assert share.expires_in == 7 - assert isinstance(share.result, ActivityResultItem) + assert isinstance(share.result, PredictionResultItem) assert share.result.score == 87 assert share.result.label == "Deepfake" @@ -98,20 +100,20 @@ def test_list_url_has_trailing_slash(client): client.shares.list() - assert route.calls.last.request.url.path == "/api/v1/activities/shares/" + assert route.calls.last.request.url.path == "/api/v1/predictions/shares" @respx.mock def test_get_share(client): """Verify that fetching a single share by ID returns the expected share object.""" - route = respx.get(f"{SHARES_URL}{SHARE_ID}").mock( + route = respx.get(f"{SHARES_URL}/{SHARE_ID}").mock( return_value=httpx.Response(200, json=share_response()) ) share = client.shares.get(SHARE_ID) assert route.called - assert route.calls.last.request.url.path == f"/api/v1/activities/shares/{SHARE_ID}" + assert route.calls.last.request.url.path == f"/api/v1/predictions/shares/{SHARE_ID}" assert share.id == SHARE_ID @@ -244,11 +246,11 @@ def test_create_minimal(client): return_value=httpx.Response(200, json=share_response()) ) - share = client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + share = client.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), } assert share.share_url == "https://elhio.com/s/abc123" @@ -260,9 +262,9 @@ def test_create_url_has_trailing_slash(client): return_value=httpx.Response(200, json=share_response()) ) - client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + client.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) - assert route.calls.last.request.url.path == "/api/v1/activities/shares/" + assert route.calls.last.request.url.path == "/api/v1/predictions/shares" @respx.mock @@ -272,7 +274,9 @@ def test_create_sends_expires_in(client): return_value=httpx.Response(200, json=share_response(expires_in=3)) ) - share = client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=3) + share = client.shares.create( + prediction_id=PREDICTION_ID, target_id=TARGET_ID, expires_in=3 + ) assert body_of(route)["expires_in"] == 3 assert share.expires_in == 3 @@ -285,7 +289,7 @@ def test_create_omits_unset_expires_in(client): return_value=httpx.Response(200, json=share_response()) ) - client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + client.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) assert "expires_in" not in body_of(route) @@ -300,7 +304,9 @@ def test_create_rejects_out_of_range_expires_in(client, bad): route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) with pytest.raises(GuardError, match="Expected between 1 and 7 days"): - client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=bad) + client.shares.create( + prediction_id=PREDICTION_ID, target_id=TARGET_ID, expires_in=bad + ) assert not route.called @@ -312,7 +318,9 @@ def test_create_rejects_non_int_expires_in(client, bad): route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) with pytest.raises(GuardError, match="Expected an integer number of days"): - client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=bad) + client.shares.create( + prediction_id=PREDICTION_ID, target_id=TARGET_ID, expires_in=bad + ) assert not route.called @@ -320,7 +328,8 @@ def test_create_rejects_non_int_expires_in(client, bad): @respx.mock def test_create_for_from_detection_result(client): """ - Verify create_for correctly extracts activity and task IDs from a DetectionResult. + Verify create_for correctly extracts prediction and target IDs from a + DetectionResult. """ route = respx.post(SHARES_URL).mock( return_value=httpx.Response(200, json=share_response()) @@ -330,39 +339,39 @@ def test_create_for_from_detection_result(client): client.shares.create_for(result, result.results[0], expires_in=1) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), "expires_in": 1, } @respx.mock -def test_create_for_from_activity_detail(client): - """Verify create_for correctly extracts IDs from an ActivityDetail object.""" +def test_create_for_from_prediction_detail(client): + """Verify create_for correctly extracts IDs from a PredictionDetail object.""" route = respx.post(SHARES_URL).mock( return_value=httpx.Response(200, json=share_response()) ) - detail = ActivityDetail.model_validate(detail_response()) + detail = PredictionDetail.model_validate(detail_response()) client.shares.create_for(detail, detail.result_payload.results[0]) assert body_of(route) == { - "activity_id": str(ACTIVITY_ID), - "task_id": str(TASK_ID), + "prediction_id": str(PREDICTION_ID), + "target_id": str(TARGET_ID), } @respx.mock def test_create_for_rejects_foreign_item(client): """ - Ensure create_for rejects an ActivityResultItem that does not belong to the source - activity. + Ensure create_for rejects a PredictionResultItem that does not belong to the source + prediction. """ route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) result = cloud_result() - stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + stranger = PredictionResultItem(target_id=uuid4(), score=1, label="Other") - with pytest.raises(GuardError, match="is not part of this activity's results"): + with pytest.raises(GuardError, match="is not part of this prediction's results"): client.shares.create_for(result, stranger) assert not route.called @@ -374,7 +383,7 @@ def test_create_for_rejects_local_result(client): route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) local = DetectionResult( engine=Engine.LOCAL, - results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + results=[PredictionResultItem(target_id=TARGET_ID, score=10, label="safe")], ) with pytest.raises(GuardError, match="local engine"): @@ -384,15 +393,15 @@ def test_create_for_rejects_local_result(client): @respx.mock -def test_create_for_rejects_activity_without_results(client): +def test_create_for_rejects_prediction_without_results(client): """ - Ensure create_for raises an error if the activity detail has no result payload. + Ensure create_for raises an error if the prediction detail has no result payload. """ route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) - detail = ActivityDetail.model_validate( + detail = PredictionDetail.model_validate( detail_response(status="processing", result_payload=None) ) - item = ActivityResultItem(task_id=TASK_ID, score=1, label="x") + item = PredictionResultItem(target_id=TARGET_ID, score=1, label="x") with pytest.raises(GuardError, match="available: none"): client.shares.create_for(detail, item) @@ -403,11 +412,15 @@ def test_create_for_rejects_activity_without_results(client): @pytest.mark.parametrize( ("status", "expected", "detail"), [ - (409, GuardConflictError, "Cannot create share for an activity multiple times"), - (404, GuardNotFoundError, "Activity not found"), + ( + 409, + GuardConflictError, + "Cannot create share for a prediction multiple times", + ), + (404, GuardNotFoundError, "Prediction not found"), (404, GuardNotFoundError, "Associated media file no longer exists"), - (400, GuardAPIError, "Invalid task"), - (400, GuardAPIError, "No media key associated with this task"), + (400, GuardAPIError, "Invalid target"), + (400, GuardAPIError, "No media key associated with this target"), ], ) @respx.mock @@ -421,7 +434,7 @@ def test_create_maps_server_errors(client, status, expected, detail): ) with pytest.raises(expected) as exc_info: - client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + client.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) assert exc_info.value.status_code == status assert detail in str(exc_info.value) @@ -440,7 +453,7 @@ def test_create_is_never_retried(monkeypatch): retrying = GuardClient(api_key=API_KEY, base_url=BASE_URL, max_retries=3) try: with pytest.raises(GuardServerError): - retrying.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + retrying.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) finally: retrying.close() @@ -455,16 +468,16 @@ def test_iter_all_walks_pages(client): httpx.Response( 200, json=page_response( - [share_response(task_name=f"s{i}") for i in range(2)], 3 + [share_response(target_name=f"s{i}") for i in range(2)], 3 ), ), httpx.Response( - 200, json=page_response([share_response(task_name="s2")], 3) + 200, json=page_response([share_response(target_name="s2")], 3) ), ] ) - names = [s.task_name for s in client.shares.iter_all(page_size=2)] + names = [s.target_name for s in client.shares.iter_all(page_size=2)] assert names == ["s0", "s1", "s2"] assert route.call_count == 2 @@ -478,7 +491,7 @@ async def test_async_list_and_get(async_client): 200, json=page_response([share_response()], count=1) ) ) - respx.get(f"{SHARES_URL}{SHARE_ID}").mock( + respx.get(f"{SHARES_URL}/{SHARE_ID}").mock( return_value=httpx.Response(200, json=share_response()) ) @@ -498,10 +511,10 @@ async def test_async_create_and_create_for(async_client): return_value=httpx.Response(200, json=share_response()) ) - await async_client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) - assert body_of(route)["activity_id"] == str(ACTIVITY_ID) + await async_client.shares.create(prediction_id=PREDICTION_ID, target_id=TARGET_ID) + assert body_of(route)["prediction_id"] == str(PREDICTION_ID) - detail = ActivityDetail.model_validate(detail_response()) + detail = PredictionDetail.model_validate(detail_response()) share = await async_client.shares.create_for( detail, detail.result_payload.results[0], expires_in=2 ) @@ -513,14 +526,14 @@ async def test_async_create_and_create_for(async_client): @respx.mock async def test_async_create_for_rejects_foreign_item(async_client): """ - Ensure async create_for rejects an ActivityResultItem from a different activity + Ensure async create_for rejects a PredictionResultItem from a different prediction before making a call. """ route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) result = cloud_result() - stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + stranger = PredictionResultItem(target_id=uuid4(), score=1, label="Other") - with pytest.raises(GuardError, match="is not part of this activity's results"): + with pytest.raises(GuardError, match="is not part of this prediction's results"): await async_client.shares.create_for(result, stranger) assert not route.called diff --git a/tests/test_spaces.py b/tests/test_spaces.py index f54d3f3..1c87260 100644 --- a/tests/test_spaces.py +++ b/tests/test_spaces.py @@ -30,13 +30,13 @@ PREDICTOR_ID, RUNNER_ID, SPACE_ID, - TASK_ID, + TARGET_ID, USER_ID, space_response, spaces_page_response, ) -SPACES_URL = f"{BASE_URL}/api/v1/spaces/" +SPACES_URL = f"{BASE_URL}/api/v1/spaces" def params_of(route): @@ -135,7 +135,7 @@ def test_create_sends_all_supplied_fields(client): description="Everything set", is_public=True, organization_id=ORG_ID, - enabled_task_ids=[TASK_ID], + enabled_target_ids=[TARGET_ID], dedicated_runner_ids=[RUNNER_ID], ) @@ -145,7 +145,7 @@ def test_create_sends_all_supplied_fields(client): "is_public": True, "description": "Everything set", "organization_id": str(ORG_ID), - "enabled_task_ids": [str(TASK_ID)], + "enabled_target_ids": [str(TARGET_ID)], "dedicated_runner_ids": [str(RUNNER_ID)], } @@ -164,7 +164,7 @@ def test_create_omits_unset_optionals(client): body = body_of(route) for absent in ( "description", - "enabled_task_ids", + "enabled_target_ids", "dedicated_runner_ids", "user_id", ): @@ -214,9 +214,9 @@ def test_create_treats_blank_description_as_unset(client): @respx.mock -def test_create_dedupes_task_ids_preserving_order(client): +def test_create_dedupes_target_ids_preserving_order(client): """ - Verify that duplicate task IDs are removed while preserving their original order. + Verify that duplicate target IDs are removed while preserving their original order. """ route = respx.post(SPACES_URL).mock( return_value=httpx.Response(200, json=space_response()) @@ -227,10 +227,10 @@ def test_create_dedupes_task_ids_preserving_order(client): name="Dupes", predictor_id=PREDICTOR_ID, organization_id=ORG_ID, - enabled_task_ids=[TASK_ID, second, TASK_ID], + enabled_target_ids=[TARGET_ID, second, TARGET_ID], ) - assert body_of(route)["enabled_task_ids"] == [str(TASK_ID), second] + assert body_of(route)["enabled_target_ids"] == [str(TARGET_ID), second] @pytest.mark.parametrize( @@ -273,7 +273,7 @@ def test_create_validates_before_sending(client, kwargs, message): (409, GuardConflictError, "A space with this name already exists"), (402, GuardPaymentRequiredError, "User needs active subscription"), (403, GuardAuthError, "Predictor is not enabled in your active subscription"), - (404, GuardNotFoundError, "One or more tasks not found"), + (404, GuardNotFoundError, "One or more targets not found"), (400, GuardAPIError, "Cannot create space without an owner"), ], ) @@ -368,7 +368,7 @@ def test_list_parses_page(client): assert space.name == "Test Space" assert space.status is SpaceStatus.ACTIVE assert space.enabled_media == [MediaCategory.IMAGE, MediaCategory.VIDEO] - assert space.enabled_task_names == ["Deepfake", "Violence"] + assert space.enabled_target_names == ["Deepfake", "Violence"] @respx.mock @@ -441,12 +441,12 @@ def test_owner_name_prefers_organization(client): @respx.mock def test_get_parses_space_detail(client): """ - Verify that space detail requests parse into SpaceDetail objects with full task + Verify that space detail requests parse into SpaceDetail objects with full target models. """ from .conftest import PREDICTOR_ID, space_detail_response - route = respx.get(f"{SPACES_URL}{SPACE_ID}").mock( + route = respx.get(f"{SPACES_URL}/{SPACE_ID}").mock( return_value=httpx.Response(200, json=space_detail_response()) ) @@ -457,10 +457,11 @@ def test_get_parses_space_detail(client): assert detail.predictor_multiplier == 3 assert detail.predictor_id == PREDICTOR_ID assert detail.max_media_size == 52428800 - # enabled_tasks are full Task objects here, not the names the list endpoint returns. - assert [t.name for t in detail.enabled_tasks] == ["Deepfake"] - assert detail.enabled_tasks[0].reactions == {1: "Real photo", 2: "AI generated"} - assert detail.task_thresholds[0].blur_threshold == 50 + # enabled_targets are full Target objects here, not the names the list endpoint + # returns. + assert [t.name for t in detail.enabled_targets] == ["Deepfake"] + assert detail.enabled_targets[0].reactions == {1: "Real photo", 2: "AI generated"} + assert detail.target_thresholds[0].blur_threshold == 50 assert detail.owner_name == "Test Org" @@ -469,7 +470,7 @@ async def test_async_get_space_detail(async_client): """Verify that the async client can fetch and parse detailed space information.""" from .conftest import space_detail_response - respx.get(f"{SPACES_URL}{SPACE_ID}").mock( + respx.get(f"{SPACES_URL}/{SPACE_ID}").mock( return_value=httpx.Response(200, json=space_detail_response()) ) diff --git a/tests/test_tasks.py b/tests/test_targets.py similarity index 52% rename from tests/test_tasks.py rename to tests/test_targets.py index 82876d5..eca321e 100644 --- a/tests/test_tasks.py +++ b/tests/test_targets.py @@ -1,5 +1,5 @@ """ -Tests for the tasks resource. +Tests for the targets resource. """ from __future__ import annotations @@ -8,53 +8,55 @@ import pytest import respx -from guard_client import GuardError, TaskOrder, TaskStatus +from guard_client import GuardError, TargetOrder, TargetStatus from .conftest import ( BASE_URL, ORG_ID, PREDICTOR_ID, - TASK_ID, + TARGET_ID, page_response, - task_response, + target_response, ) -TASKS_URL = f"{BASE_URL}/api/v1/tasks/" +TARGETS_URL = f"{BASE_URL}/api/v1/targets" @respx.mock -def test_list_parses_tasks(client): +def test_list_parses_targets(client): """ - Verify that listing tasks correctly parses all fields including reaction dictionary - key types. + Verify that listing targets correctly parses all fields including reaction + dictionary key types. """ - respx.get(TASKS_URL).mock( - return_value=httpx.Response(200, json=page_response([task_response()], count=4)) + respx.get(TARGETS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([target_response()], count=4) + ) ) - page = client.tasks.list() + page = client.targets.list() assert page.count == 4 - task = page[0] - assert task.id == TASK_ID - assert task.name == "Deepfake" - assert task.status is TaskStatus.ACTIVE - assert task.description == "Detects synthetic media" + target = page[0] + assert target.id == TARGET_ID + assert target.name == "Deepfake" + assert target.status is TargetStatus.ACTIVE + assert target.description == "Detects synthetic media" # keys arrive as JSON strings but are coerced to the ints the API means them as - assert task.reactions == {1: "Real photo", 2: "AI generated"} - assert all(isinstance(key, int) for key in task.reactions) + assert target.reactions == {1: "Real photo", 2: "AI generated"} + assert all(isinstance(key, int) for key in target.reactions) @respx.mock def test_list_filters_by_predictor(client): """ - Ensure that filtering tasks by predictor_id sends the expected query parameter. + Ensure that filtering targets by predictor_id sends the expected query parameter. """ - route = respx.get(TASKS_URL).mock( + route = respx.get(TARGETS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) - client.tasks.list(predictor_id=PREDICTOR_ID, sort_by="name", limit=50) + client.targets.list(predictor_id=PREDICTOR_ID, sort_by="name", limit=50) params = route.calls.last.request.url.params assert params["predictor_id"] == str(PREDICTOR_ID) @@ -64,12 +66,12 @@ def test_list_filters_by_predictor(client): @respx.mock def test_list_omits_unset_filters(client): - """Verify that unset task filters are omitted from query parameters.""" - route = respx.get(TASKS_URL).mock( + """Verify that unset target filters are omitted from query parameters.""" + route = respx.get(TARGETS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) - client.tasks.list() + client.targets.list() params = route.calls.last.request.url.params for absent in ( @@ -88,12 +90,12 @@ def test_conflicting_owner_filters_raise_before_any_request(client): Ensure specifying both user_id and organization_id filters raises an error locally before calling the API, which answers 400 to that pair. """ - route = respx.get(TASKS_URL).mock( + route = respx.get(TARGETS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) with pytest.raises(GuardError, match="both user_id and organization_id"): - client.tasks.list(user_id=TASK_ID, organization_id=ORG_ID) + client.targets.list(user_id=TARGET_ID, organization_id=ORG_ID) assert not route.called @@ -112,10 +114,10 @@ def test_list_validates_before_sending(client, kwargs, message): Verify that invalid query arguments trigger client-side validation errors before calling the API. """ - route = respx.get(TASKS_URL).mock(return_value=httpx.Response(200)) + route = respx.get(TARGETS_URL).mock(return_value=httpx.Response(200)) with pytest.raises(GuardError, match=message): - client.tasks.list(**kwargs) + client.targets.list(**kwargs) assert not route.called @@ -123,13 +125,13 @@ def test_list_validates_before_sending(client, kwargs, message): @respx.mock def test_enum_member_accepted(client): """ - Verify that TaskOrder enum members are accepted directly as sort_by parameters. + Verify that TargetOrder enum members are accepted directly as sort_by parameters. """ - route = respx.get(TASKS_URL).mock( + route = respx.get(TARGETS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) - client.tasks.list(sort_by=TaskOrder.CREATED_AT) + client.targets.list(sort_by=TargetOrder.CREATED_AT) assert route.calls.last.request.url.params["sort_by"] == "created_at" @@ -137,36 +139,38 @@ def test_enum_member_accepted(client): @respx.mock def test_missing_optional_fields_tolerated(client): """ - Verify that task responses missing description or reactions default to None or empty - dictionary. + Verify that target responses missing description or reactions default to None or + empty dictionary. """ - minimal = {"id": str(TASK_ID), "status": "active", "name": "Bare"} - respx.get(TASKS_URL).mock( + minimal = {"id": str(TARGET_ID), "status": "active", "name": "Bare"} + respx.get(TARGETS_URL).mock( return_value=httpx.Response(200, json=page_response([minimal], count=1)) ) - task = client.tasks.list()[0] + target = client.targets.list()[0] - assert task.description is None - assert task.reactions == {} + assert target.description is None + assert target.reactions == {} @respx.mock def test_iter_all_walks_pages(client): """ - Ensure that iter_all transparently fetches across multiple pages of tasks. + Ensure that iter_all transparently fetches across multiple pages of targets. """ - route = respx.get(TASKS_URL).mock( + route = respx.get(TARGETS_URL).mock( side_effect=[ httpx.Response( 200, - json=page_response([task_response(name=f"t{i}") for i in range(2)], 3), + json=page_response( + [target_response(name=f"t{i}") for i in range(2)], 3 + ), ), - httpx.Response(200, json=page_response([task_response(name="t2")], 3)), + httpx.Response(200, json=page_response([target_response(name="t2")], 3)), ] ) - names = [t.name for t in client.tasks.iter_all(page_size=2)] + names = [t.name for t in client.targets.iter_all(page_size=2)] assert names == ["t0", "t1", "t2"] assert route.call_count == 2 @@ -174,11 +178,13 @@ def test_iter_all_walks_pages(client): @respx.mock async def test_async_list(async_client): - """Verify that the async client correctly lists and parses tasks.""" - respx.get(TASKS_URL).mock( - return_value=httpx.Response(200, json=page_response([task_response()], count=1)) + """Verify that the async client correctly lists and parses targets.""" + respx.get(TARGETS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([target_response()], count=1) + ) ) - page = await async_client.tasks.list(predictor_id=PREDICTOR_ID) + page = await async_client.targets.list(predictor_id=PREDICTOR_ID) - assert page[0].id == TASK_ID + assert page[0].id == TARGET_ID diff --git a/uv.lock b/uv.lock index ade09e3..ce355a5 100644 --- a/uv.lock +++ b/uv.lock @@ -163,25 +163,22 @@ wheels = [ [[package]] name = "c2pa-python" -version = "0.37.4" +version = "0.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "pytest" }, { name = "requests" }, - { name = "setuptools" }, { name = "toml" }, - { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/6c/0e28502553bc32dc98f38a5415e20fa444e15e19ee7d17c8992cc8b23a28/c2pa_python-0.37.4.tar.gz", hash = "sha256:6aebfe6f669077d0662dcd9ad3612c40baf888ae22d2f4866d383a9f7dd46aee", size = 112919, upload-time = "2026-08-04T21:30:37.317Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b1/2ff409fdfe7304fc2e4074ebbe8079274e21bdbbf60b34bd7f9a3e60670f/c2pa_python-0.38.0.tar.gz", hash = "sha256:3a40dab7424e03b397f5c4c81298d7f534a28e7d75a17d5d39fdc35c5dfe5963", size = 137647, upload-time = "2026-09-29T21:05:01.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b6/3b8041a3d163168489d1458e63c56cb7a782d36298520f8eac83b7da928c/c2pa_python-0.37.4-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d2814c7d9c2759a7dec6404779aead36104fe807886ecf6c6cbd2af26c2ab3f0", size = 16183400, upload-time = "2026-08-04T21:30:13.39Z" }, - { url = "https://files.pythonhosted.org/packages/d2/22/199a897c0b526fec6c109c947db6c2010da70b782841e9a3f6c72af026de/c2pa_python-0.37.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:839123d8854802848695ce24c7dd56661be56fbb815818de19f0d6a6d8d2bc16", size = 14148222, upload-time = "2026-08-04T21:30:16.092Z" }, - { url = "https://files.pythonhosted.org/packages/a7/79/7f9c670d644eb1681cf304fc22d5b31ea26cf9b9bece37a2ca4b1c5cd624/c2pa_python-0.37.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:243260fdd577549185175daaf9fdb4f30c8e940aaaf555b34c3579c6b4549c6c", size = 13782417, upload-time = "2026-08-04T21:30:18.885Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/4ba5294e9f32d97bb79ae8ea6ca0c9401c36e4e783fef3dcf1ebe6099830/c2pa_python-0.37.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:baa39c2befedcb1c45c0cb439a3b2b4f0c428ab3126e56c02b59a2e3700212f9", size = 13987587, upload-time = "2026-08-04T21:30:21.456Z" }, - { url = "https://files.pythonhosted.org/packages/79/72/be5e4db496a4c52134970ffcd14bd557c7c25c6b9fa6f55166c08776a80f/c2pa_python-0.37.4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8e2b1b214ea4e885178e1bd6c330d2c4c0c0d3aaa9cdb3bc28f7e7a7144ee5c0", size = 14636994, upload-time = "2026-08-04T21:30:24.021Z" }, - { url = "https://files.pythonhosted.org/packages/56/ef/575cfa3f60be714056fa1e6118375b92dbc252c3a63e0d37893a521f62e5/c2pa_python-0.37.4-py3-none-win_amd64.whl", hash = "sha256:4133e8e99059eeb8be0023fcb01aea17a588fe23911458813d6dc451d6eff712", size = 86337929, upload-time = "2026-08-04T21:30:28.796Z" }, - { url = "https://files.pythonhosted.org/packages/19/b6/f933ea182270f8285c66181a420a8fbcfa8a83ba590f265dd5dc864e3c26/c2pa_python-0.37.4-py3-none-win_arm64.whl", hash = "sha256:e183af9a6d511352cc427bed22611ea1b9dd1dd13620ec5555fb6c12643e6779", size = 84009543, upload-time = "2026-08-04T21:30:34.501Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d9/90ad8810bb0638da95bd5ad814dd15cb843886edb9b6eeab3f1c6c139db1/c2pa_python-0.38.0-py3-none-macosx_10_9_universal2.whl", hash = "sha256:334ffbff033423c910f1e635447cd775c1d66874e02b6db55d6f62d7a5a856fc", size = 16937345, upload-time = "2026-09-29T21:04:41.275Z" }, + { url = "https://files.pythonhosted.org/packages/69/1c/2fab71e40c510ccaad4f2032f751f336d027b2235f10fe41f09cf44fffb7/c2pa_python-0.38.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d1b5522673c0df8b64f85fc9f55bab5f211920fb571669bd8461a8ffe7e3a287", size = 14806621, upload-time = "2026-09-29T21:04:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/09/3c/04b75a601202c8e90116472e325dc2ae2c1f371153b8b8244e8b1b619ca2/c2pa_python-0.38.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53364e6b02b2c26863419f444bb6390e8c4c4095eb0e3c3373c7144e7a7ff363", size = 14451808, upload-time = "2026-09-29T21:04:46.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/74711ff71b5c4448322e5af27160e5f4032bda8dec9ab4c6534733e119b9/c2pa_python-0.38.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dcdc16c23178cb580c1017dca88ca2f6f516b3748ecf4c69acfff78ea385feb8", size = 14677363, upload-time = "2026-09-29T21:04:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/85a91e6262e0399e88596f79b73d2953f68db98aa1c8b18225121c7bcd70/c2pa_python-0.38.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9739589e69e5891da80d9b2cc2549c8a9b224d9131cafed4582d5746a155fd2d", size = 15334006, upload-time = "2026-09-29T21:04:51.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/075dac1e82584faa7b69dfa08d101d9c82872972f3f4a8a93a117c706d97/c2pa_python-0.38.0-py3-none-win_amd64.whl", hash = "sha256:5db598a420242229a802c07e84890adb5162aac79afb110e4563c56f33e35256", size = 87904872, upload-time = "2026-09-29T21:04:54.755Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ce/a4550a56b5f1b1f898c1081145606d6a64e26d93eb7c6bf51f11da899d4a/c2pa_python-0.38.0-py3-none-win_arm64.whl", hash = "sha256:abac93851169c016aadb7251177656c3919af5ce469c011ff4ea014bd02501b4", size = 85299399, upload-time = "2026-09-29T21:04:59.1Z" }, ] [[package]] @@ -543,7 +540,7 @@ wheels = [ [[package]] name = "guard-client" -version = "0.0.1" +version = "0.0.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -573,7 +570,7 @@ docs = [ [package.metadata] requires-dist = [ - { name = "guard-local-detector", marker = "extra == 'local'", specifier = ">=0.0.1" }, + { name = "guard-local-detector", marker = "extra == 'local'", specifier = ">=0.0.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.0" }, { name = "python-dotenv", specifier = ">=1.0" }, @@ -596,7 +593,7 @@ docs = [ [[package]] name = "guard-local-detector" -version = "0.0.1" +version = "0.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "av", version = "17.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -610,9 +607,9 @@ dependencies = [ { name = "pillow" }, { name = "pillow-heif" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/38/daaef254940af0df2c2accbd105e42b5cf5b0589a70fc0b1033f1052376a/guard_local_detector-0.0.1.tar.gz", hash = "sha256:c0b9a84f6f3b76df743feef5db3b0df02aafe3cdf1edacd08168563467f592e6", size = 7384259, upload-time = "2026-08-07T06:36:32.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/f1/89e3411bfa50e596703716a8d17f6e5f0488415a10290e88997cda091efa/guard_local_detector-0.0.2.tar.gz", hash = "sha256:8a63c9cc65caf4adc9299f97f866a2ba52e9ca5b69c0a5e1cb716bd64b446c4d", size = 7383631, upload-time = "2026-10-20T09:10:51.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/fe/20308da79e835ba339f1ae2c92aef04d4eb9c26de17faca2b829d17182cd/guard_local_detector-0.0.1-py3-none-any.whl", hash = "sha256:d71c8031c79153c37ee211cc111b04cf379b277f29a0108d280e66627c87d905", size = 7273404, upload-time = "2026-08-07T06:36:30.143Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/28cd825ead07a341d0c8d9229b4eddf66e19be317e5ed224bad29d065f2e/guard_local_detector-0.0.2-py3-none-any.whl", hash = "sha256:c63901a877bc83f4f8f8cc00098398741b5a0a88190b6abc9358f81c6604bca1", size = 7273464, upload-time = "2026-10-20T09:10:50.059Z" }, ] [[package]] @@ -1750,15 +1747,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] -[[package]] -name = "setuptools" -version = "83.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, -] - [[package]] name = "stack-data" version = "0.6.3" @@ -1895,15 +1883,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] - -[[package]] -name = "wheel" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, -]