From 60098faca4fcbd85becddddba3140cba90b20c2f Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 13:09:34 +0200 Subject: [PATCH 01/15] feat: Share runs via Python SDK --- .../openapi_1.6.0+dev.e1f10d7ad5b.json | 4356 +++++++++++++++++ codegen/in/openapi.json | 83 +- codegen/out/aignx/codegen/api/public_api.py | 55 +- codegen/out/aignx/codegen/api_client.py | 2 +- codegen/out/aignx/codegen/configuration.py | 4 +- codegen/out/aignx/codegen/exceptions.py | 2 +- .../models/application_read_response.py | 2 +- .../models/application_read_short_response.py | 2 +- .../codegen/models/application_version.py | 2 +- .../aignx/codegen/models/artifact_output.py | 2 +- .../aignx/codegen/models/artifact_state.py | 2 +- .../models/artifact_termination_reason.py | 2 +- .../models/custom_metadata_update_request.py | 2 +- .../models/custom_metadata_update_response.py | 2 +- .../codegen/models/grant_create_request.py | 2 +- .../codegen/models/grant_read_response.py | 2 +- .../aignx/codegen/models/grant_relation.py | 2 +- .../codegen/models/http_validation_error.py | 2 +- .../aignx/codegen/models/input_artifact.py | 2 +- .../models/input_artifact_creation_request.py | 2 +- .../input_artifact_result_read_response.py | 2 +- .../codegen/models/item_creation_request.py | 2 +- .../out/aignx/codegen/models/item_output.py | 2 +- .../models/item_result_read_response.py | 2 +- .../out/aignx/codegen/models/item_state.py | 2 +- .../codegen/models/item_termination_reason.py | 2 +- .../aignx/codegen/models/me_read_response.py | 2 +- .../models/organization_read_response.py | 2 +- .../aignx/codegen/models/output_artifact.py | 2 +- .../output_artifact_result_read_response.py | 2 +- .../codegen/models/output_artifact_scope.py | 2 +- .../models/output_artifact_visibility.py | 2 +- .../out/aignx/codegen/models/resource_type.py | 2 +- .../codegen/models/run_creation_request.py | 2 +- .../codegen/models/run_creation_response.py | 2 +- .../codegen/models/run_item_statistics.py | 2 +- .../out/aignx/codegen/models/run_output.py | 2 +- .../aignx/codegen/models/run_read_response.py | 11 +- codegen/out/aignx/codegen/models/run_state.py | 2 +- .../codegen/models/run_termination_reason.py | 2 +- .../codegen/models/scheduling_request.py | 2 +- .../codegen/models/scheduling_response.py | 2 +- .../models/share_token_create_request.py | 2 +- .../models/share_token_create_response.py | 2 +- .../models/share_token_read_response.py | 2 +- .../out/aignx/codegen/models/subject_type.py | 2 +- .../codegen/models/user_read_response.py | 2 +- .../aignx/codegen/models/validation_error.py | 2 +- .../models/validation_error_loc_inner.py | 2 +- .../models/version_document_response.py | 2 +- .../models/version_document_visibility.py | 2 +- .../codegen/models/version_read_response.py | 2 +- codegen/out/aignx/codegen/rest.py | 2 +- codegen/out/docs/PublicApi.md | 15 +- src/aignostics/application/_cli.py | 188 + src/aignostics/application/_service.py | 159 +- src/aignostics/platform/_client.py | 3 + src/aignostics/platform/resources/access.py | 197 + src/aignostics/platform/resources/runs.py | 84 + .../platform/resources/access_test.py | 396 ++ .../platform/resources/run_sharing_test.py | 420 ++ 61 files changed, 6006 insertions(+), 61 deletions(-) create mode 100644 codegen/in/archive/openapi_1.6.0+dev.e1f10d7ad5b.json create mode 100644 src/aignostics/platform/resources/access.py create mode 100644 tests/aignostics/platform/resources/access_test.py create mode 100644 tests/aignostics/platform/resources/run_sharing_test.py diff --git a/codegen/in/archive/openapi_1.6.0+dev.e1f10d7ad5b.json b/codegen/in/archive/openapi_1.6.0+dev.e1f10d7ad5b.json new file mode 100644 index 000000000..eaa081738 --- /dev/null +++ b/codegen/in/archive/openapi_1.6.0+dev.e1f10d7ad5b.json @@ -0,0 +1,4356 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Aignostics Platform API", + "description": "\nThe Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. \n\nTo begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. \n\nMore information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com).\n\n**How to authorize and test API endpoints:**\n\n1. Click the \"Authorize\" button in the right corner below\n3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials\n4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint\n\n**Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized.\n\n", + "version": "1.6.0+dev.e1f10d7ad5b" + }, + "servers": [ + { + "url": "/api" + } + ], + "paths": { + "/v1/applications": { + "get": { + "tags": [ + "Public" + ], + "summary": "List available applications", + "description": "Returns the list of the applications, available to the caller.\n\nThe application is available if any of the versions of the application is assigned to the caller's organization.\nThe response is paginated and sorted according to the provided parameters.", + "operationId": "list_applications_v1_applications_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page-size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 5, + "default": 50, + "title": "Page-Size" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.\n\n**Available fields:**\n- `application_id`\n- `name`\n- `description`\n- `regulatory_classes`\n\n**Examples:**\n- `?sort=application_id` - Sort by application_id ascending\n- `?sort=-name` - Sort by name descending\n- `?sort=+description&sort=name` - Sort by description ascending, then name descending", + "title": "Sort" + }, + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.\n\n**Available fields:**\n- `application_id`\n- `name`\n- `description`\n- `regulatory_classes`\n\n**Examples:**\n- `?sort=application_id` - Sort by application_id ascending\n- `?sort=-name` - Sort by name descending\n- `?sort=+description&sort=name` - Sort by description ascending, then name descending" + } + ], + "responses": { + "200": { + "description": "A list of applications available to the caller", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationReadShortResponse" + }, + "title": "Response List Applications V1 Applications Get" + }, + "example": [ + { + "application_id": "he-tme", + "name": "Atlas H&E-TME", + "regulatory_classes": [ + "RUO" + ], + "description": "The Atlas H&E TME is an AI application designed to examine FFPE (formalin-fixed, paraffin-embedded) tissues stained with H&E (hematoxylin and eosin), delivering comprehensive insights into the tumor microenvironment.", + "latest_version": { + "number": "1.0.0", + "released_at": "2025-09-01T19:01:05.401Z" + } + }, + { + "application_id": "test-app", + "name": "Test Application", + "regulatory_classes": [ + "RUO" + ], + "description": "This is the test application with two algorithms: TissueQc and Tissue Segmentation", + "latest_version": { + "number": "2.0.0", + "released_at": "2025-09-02T19:01:05.401Z" + } + } + ] + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing authentication" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/applications/{application_id}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Read Application By Id", + "description": "Retrieve details of a specific application by its ID.", + "operationId": "read_application_by_id_v1_applications__application_id__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to see this application" + }, + "404": { + "description": "Not Found - Application with the given ID does not exist" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/applications/{application_id}/versions/{version}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Application Version Details", + "description": "Get the application version details.\n\nAllows caller to retrieve information about application version based on provided application version ID.", + "operationId": "application_version_details_v1_applications__application_id__versions__version__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionReadResponse" + }, + "example": { + "version_number": "0.4.4", + "changelog": "New deployment", + "input_artifacts": [ + { + "name": "whole_slide_image", + "mime_type": "image/tiff", + "metadata_schema": { + "type": "object", + "$defs": { + "LungCancerMetadata": { + "type": "object", + "title": "LungCancerMetadata", + "required": [ + "type", + "tissue" + ], + "properties": { + "type": { + "enum": [ + "lung" + ], + "type": "string", + "const": "lung", + "title": "Type" + }, + "tissue": { + "enum": [ + "lung", + "lymph node", + "liver", + "adrenal gland", + "bone", + "brain" + ], + "type": "string", + "title": "Tissue" + } + }, + "additionalProperties": false + } + }, + "title": "ExternalImageMetadata", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "checksum_crc32c", + "base_mpp", + "width", + "height", + "cancer" + ], + "properties": { + "stain": { + "enum": [ + "H&E" + ], + "type": "string", + "const": "H&E", + "title": "Stain", + "default": "H&E" + }, + "width": { + "type": "integer", + "title": "Width", + "maximum": 150000, + "minimum": 1 + }, + "cancer": { + "anyOf": [ + { + "$ref": "#/$defs/LungCancerMetadata" + } + ], + "title": "Cancer" + }, + "height": { + "type": "integer", + "title": "Height", + "maximum": 150000, + "minimum": 1 + }, + "base_mpp": { + "type": "number", + "title": "Base Mpp", + "maximum": 0.5, + "minimum": 0.125 + }, + "mime_type": { + "enum": [ + "application/dicom", + "image/tiff" + ], + "type": "string", + "title": "Mime Type", + "default": "image/tiff" + }, + "checksum_crc32c": { + "type": "string", + "title": "Checksum Crc32C" + } + }, + "description": "Metadata corresponding to an external image.", + "additionalProperties": false + } + } + ], + "output_artifacts": [ + { + "name": "tissue_qc:tiff_heatmap", + "mime_type": "image/tiff", + "metadata_schema": { + "type": "object", + "title": "HeatmapMetadata", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "checksum_crc32c", + "width", + "height", + "class_colors" + ], + "properties": { + "width": { + "type": "integer", + "title": "Width" + }, + "height": { + "type": "integer", + "title": "Height" + }, + "base_mpp": { + "type": "number", + "title": "Base Mpp", + "maximum": 0.5, + "minimum": 0.125 + }, + "mime_type": { + "enum": [ + "image/tiff" + ], + "type": "string", + "const": "image/tiff", + "title": "Mime Type", + "default": "image/tiff" + }, + "class_colors": { + "type": "object", + "title": "Class Colors", + "additionalProperties": { + "type": "array", + "maxItems": 3, + "minItems": 3, + "prefixItems": [ + { + "type": "integer", + "maximum": 255, + "minimum": 0 + }, + { + "type": "integer", + "maximum": 255, + "minimum": 0 + }, + { + "type": "integer", + "maximum": 255, + "minimum": 0 + } + ] + } + }, + "checksum_crc32c": { + "type": "string", + "title": "Checksum Crc32C" + } + }, + "description": "Metadata corresponding to a segmentation heatmap file.", + "additionalProperties": false + }, + "scope": "ITEM", + "visibility": "EXTERNAL" + } + ], + "released_at": "2025-04-16T08:45:20.655972Z" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to see this version" + }, + "404": { + "description": "Not Found - Application version with given ID is not available to you or does not exist" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs": { + "get": { + "tags": [ + "Public" + ], + "summary": "List Runs", + "description": "List runs with filtering, sorting, and pagination capabilities.\n\nReturns paginated runs that were submitted by the user.", + "operationId": "list_runs_v1_runs_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional application ID filter", + "examples": [ + "tissue-segmentation", + "heta" + ], + "title": "Application Id" + }, + "description": "Optional application ID filter" + }, + { + "name": "application_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional Version Name", + "examples": [ + "1.0.2", + "1.0.1-beta2" + ], + "title": "Application Version" + }, + "description": "Optional Version Name" + }, + { + "name": "external_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optionally filter runs by items with this external ID", + "examples": [ + "slide_001", + "patient_12345_sample_A" + ], + "title": "External Id" + }, + "description": "Optionally filter runs by items with this external ID" + }, + { + "name": "custom_metadata", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata.\n#### URL Encoding Required\n**Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding.\n\n#### Examples (Clear Format):\n- **Field existence**: `$.study` - Runs that have a study field defined\n- **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value\n- **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75\n- **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\"\n- **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements\n\n#### Examples (URL-Encoded Format):\n- **Field existence**: `%24.study`\n- **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)`\n- **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)`\n- **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)`\n- **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)`\n\n#### Notes\n- JSONPath expressions are evaluated using PostgreSQL's `@?` operator\n- The `$.` prefix is automatically added to root-level field references if missing\n- String values in conditions must be enclosed in double quotes\n- Use `&&` for AND operations and `||` for OR operations\n- Regular expressions use `like_regex` with standard regex syntax\n- **Please remember to URL-encode the entire JSONPath expression when making HTTP requests**\n\n ", + "title": "Custom Metadata" + }, + "description": "Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata.\n#### URL Encoding Required\n**Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding.\n\n#### Examples (Clear Format):\n- **Field existence**: `$.study` - Runs that have a study field defined\n- **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value\n- **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75\n- **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\"\n- **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements\n\n#### Examples (URL-Encoded Format):\n- **Field existence**: `%24.study`\n- **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)`\n- **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)`\n- **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)`\n- **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)`\n\n#### Notes\n- JSONPath expressions are evaluated using PostgreSQL's `@?` operator\n- The `$.` prefix is automatically added to root-level field references if missing\n- String values in conditions must be enclosed in double quotes\n- Use `&&` for AND operations and `||` for OR operations\n- Regular expressions use `like_regex` with standard regex syntax\n- **Please remember to URL-encode the entire JSONPath expression when making HTTP requests**\n\n ", + "examples": { + "no_filter": { + "summary": "No filter (returns all)", + "description": "Returns all items without filtering by custom metadata", + "value": "$" + }, + "field_exists": { + "summary": "Check if field exists", + "description": "Find applications that have a project field defined", + "value": "$.study" + }, + "field_has_value": { + "summary": "Check if field has a certain value", + "description": "Compare a field value against a certain value", + "value": "$.study ? (@ == \"abc-1\")" + }, + "numeric_comparisons": { + "summary": "Compare to a numeric value of a field", + "description": "Compare a field value against a numeric value of a field", + "value": "$.confidence_score ? (@ > 0.75)" + }, + "array_operations": { + "summary": "Check if an array contains a certain value", + "description": "Check if an array contains a certain value", + "value": "$.tags[*] ? (@ == \"draft\")" + }, + "complex_filters": { + "summary": "Combine multiple checks", + "description": "Combine multiple checks", + "value": "$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)" + } + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 5, + "default": 50, + "title": "Page Size" + } + }, + { + "name": "submitted_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user.", + "examples": [ + "me", + "auth0|123456789" + ], + "title": "Submitted By" + }, + "description": "Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user." + }, + { + "name": "organization_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization.", + "examples": [ + "my_org", + "org_acme" + ], + "title": "Organization Id" + }, + "description": "Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization." + }, + { + "name": "for_organization", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs.", + "title": "For Organization" + }, + "description": "Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs." + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.\n\n**Available fields:**\n- `run_id`\n- `application_id`\n- `version_number`\n- `custom_metadata`\n- `submitted_at`\n- `submitted_by`\n- `terminated_at`\n- `termination_reason`\n\n**Examples:**\n- `?sort=submitted_at` - Sort by creation time (ascending)\n- `?sort=-submitted_at` - Sort by creation time (descending)\n- `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending)\n", + "title": "Sort" + }, + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.\n\n**Available fields:**\n- `run_id`\n- `application_id`\n- `version_number`\n- `custom_metadata`\n- `submitted_at`\n- `submitted_by`\n- `terminated_at`\n- `termination_reason`\n\n**Examples:**\n- `?sort=submitted_at` - Sort by creation time (ascending)\n- `?sort=-submitted_at` - Sort by creation time (descending)\n- `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending)\n" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunReadResponse" + }, + "title": "Response List Runs V1 Runs Get" + } + } + } + }, + "404": { + "description": "Run not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "Public" + ], + "summary": "Initiate Run", + "description": "This endpoint initiates a processing run for a selected application and version, and returns a `run_id` for tracking purposes.\n\nSlide processing occurs asynchronously, allowing you to retrieve results for individual slides as soon as they\ncomplete processing. The system typically processes slides in batches.\nBelow is an example of the required payload for initiating an Atlas H&E TME processing run.\n\n\n### Payload\n\nThe payload includes `application_id`, optional `version_number`, and `items` base fields.\n\n`application_id` is the unique identifier for the application.\n`version_number` is the semantic version to use. If not provided, the latest available version will be used.\n\n`items` includes the list of the items to process (slides, in case of HETA application).\nEvery item has a set of standard fields defined by the API, plus the custom_metadata, specific to the\nchosen application.\n\nExample payload structure with the comments:\n```\n{\n application_id: \"he-tme\",\n version_number: \"1.0.0-beta\",\n items: [{\n \"external_id\": \"slide_1\",\n \"custom_metadata\": {\"project\": \"sample-study\"},\n \"input_artifacts\": [{\n \"name\": \"user_slide\",\n \"download_url\": \"https://...\",\n \"metadata\": {\n \"specimen\": {\n \"disease\": \"LUNG_CANCER\",\n \"tissue\": \"LUNG\"\n },\n \"staining_method\": \"H&E\",\n \"width_px\": 136223,\n \"height_px\": 87761,\n \"resolution_mpp\": 0.2628238,\n \"media-type\":\"image/tiff\",\n \"checksum_base64_crc32c\": \"64RKKA==\"\n }\n }]\n }]\n}\n```\n\n| Parameter | Description |\n| :---- | :---- |\n| `application_id` required | Unique ID for the application |\n| `version_number` optional | Semantic version of the application. If not provided, the latest available version will be used |\n| `items` required | List of submitted items i.e. whole slide images (WSIs) with parameters described below. |\n| `external_id` required | Unique WSI name or ID for easy reference to items, provided by the caller. The `external_id` should be unique across all items of the run. |\n| `input_artifacts` required | List of provided artifacts for a WSI; at the moment Atlas H&E-TME receives only 1 artifact per slide (the slide itself), but for some other applications this can be a slide and a segmentation map |\n| `name` required | Type of artifact; Atlas H&E-TME supports only `\"input_slide\"` |\n| `download_url` required | Signed URL to the input file in the S3 or GCS; Should be valid for at least 6 days |\n| `specimen: disease` required | Supported cancer types for Atlas H&E-TME (see full list in Atlas H&E-TME manual) |\n| `specimen: tissue` required | Supported tissue types for Atlas H&E-TME (see full list in Atlas H&E-TME manual) |\n| `staining_method` required | WSI stain bio-marker; Atlas H&E-TME supports only `\"H&E\"` |\n| `width_px` required | Integer value. Number of pixels of the WSI in the X dimension. |\n| `height_px` required | Integer value. Number of pixels of the WSI in the Y dimension. |\n| `resolution_mpp` required | Resolution of WSI in micrometers per pixel; check allowed range in Atlas H&E-TME manual |\n| `media-type` required | Supported media formats; available values are: image/tiff (for .tiff or .tif WSI), application/dicom (for DICOM ), application/zip (for zipped DICOM), and application/octet-stream (for .svs WSI) |\n| `checksum_base64_crc32c` required | Base64-encoded big-endian CRC32C checksum of the WSI image |\n\n\n\n### Response\n\nThe endpoint returns the run UUID. After that, the job is scheduled for the execution in the background.\n\nTo check the status of the run, call `GET v1/runs/{run_id}` endpoint with the returned run UUID.\n\n### Rejection\n\nApart from the authentication, authorization, and malformed input error, the request can be\nrejected when specific quota limit is exceeded. More details on quotas is described in the\ndocumentation", + "operationId": "create_run_v1_runs_post", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunCreationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunCreationResponse" + } + } + } + }, + "404": { + "description": "Application version not found" + }, + "403": { + "description": "Forbidden - You don't have permission to create this run" + }, + "400": { + "description": "Bad Request - Input validation failed" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get run details", + "description": "This endpoint allows the caller to retrieve the current status of a run along with other relevant run details.\n A run becomes available immediately after it is created through the `POST /v1/runs/` endpoint.\n\n To download the output results, use `GET /v1/runs/{run_id}/` items to get outputs for all slides.\nAccess to a run is restricted to the user who created it, or users with an active grant or valid share token.", + "operationId": "get_run_v1_runs__run_id__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /v1/runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /v1/runs/` endpoint" + }, + { + "name": "share_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Share token for accessing shared runs", + "title": "Share Token" + }, + "description": "Share token for accessing shared runs" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunReadResponse" + } + } + } + }, + "404": { + "description": "Run not found because it was deleted." + }, + "403": { + "description": "Forbidden - You don't have permission to see this run" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/cancel": { + "post": { + "tags": [ + "Public" + ], + "summary": "Cancel Run", + "description": "The run can be canceled by the user who created the run.\n\nThe execution can be canceled any time while the run is not in the terminated state. The\npending items of a canceled run will not be processed and will not add to the cost.\n\nWhen the run is canceled, the already completed items remain available for download.", + "operationId": "cancel_run_v1_runs__run_id__cancel_post", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /runs/` endpoint" + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Run not found" + }, + "403": { + "description": "Forbidden - You don't have permission to cancel this run" + }, + "409": { + "description": "Conflict - The Run is already cancelled" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/items": { + "get": { + "tags": [ + "Public" + ], + "summary": "List Run Items", + "description": "List items in a run with filtering, sorting, and pagination capabilities.\n\nReturns paginated items within a specific run. Results can be filtered\nby `item_id`, `external_ids`, `custom_metadata`, `terminated_at`, and `termination_reason` using JSONPath expressions.\n\n## JSONPath Metadata Filtering\nUse PostgreSQL JSONPath expressions to filter items using their custom_metadata.\n\n### Examples:\n- **Field existence**: `$.case_id` - Results that have a case_id field defined\n- **Exact value match**: `$.priority ? (@ == \"high\")` - Results with high priority\n- **Numeric comparison**: `$.confidence_score ? (@ > 0.95)` - Results with high confidence\n- **Array operations**: `$.flags[*] ? (@ == \"reviewed\")` - Results flagged as reviewed\n- **Complex conditions**: `$.metrics ? (@.accuracy > 0.9 && @.recall > 0.8)` - Results meeting performance thresholds\n\n## Notes\n- JSONPath expressions are evaluated using PostgreSQL's `@?` operator\n- The `$.` prefix is automatically added to root-level field references if missing\n- String values in conditions must be enclosed in double quotes\n- Use `&&` for AND operations and `||` for OR operations", + "operationId": "list_run_items_v1_runs__run_id__items_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /v1/runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /v1/runs/` endpoint" + }, + { + "name": "share_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Share token for accessing shared runs", + "title": "Share Token" + }, + "description": "Share token for accessing shared runs" + }, + { + "name": "item_id__in", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "description": "Filter for item ids", + "title": "Item Id In" + }, + "description": "Filter for item ids" + }, + { + "name": "external_id__in", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Filter for items by their external_id from the input payload", + "title": "External Id In" + }, + "description": "Filter for items by their external_id from the input payload" + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ItemState" + }, + { + "type": "null" + } + ], + "description": "Filter items by their state", + "title": "State" + }, + "description": "Filter items by their state" + }, + { + "name": "termination_reason", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ItemTerminationReason" + }, + { + "type": "null" + } + ], + "description": "Filter items by their termination reason. Only applies to TERMINATED items.", + "title": "Termination Reason" + }, + "description": "Filter items by their termination reason. Only applies to TERMINATED items." + }, + { + "name": "custom_metadata", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ], + "description": "JSONPath expression to filter items by their custom_metadata", + "title": "Custom Metadata" + }, + "description": "JSONPath expression to filter items by their custom_metadata", + "examples": { + "no_filter": { + "summary": "No filter (returns all)", + "description": "Returns all items without filtering by custom metadata", + "value": "$" + }, + "field_exists": { + "summary": "Check if field exists", + "description": "Find items that have a project field defined", + "value": "$.project" + }, + "field_has_value": { + "summary": "Check if field has a certain value", + "description": "Compare a field value against a certain value", + "value": "$.project ? (@ == \"cancer-research\")" + }, + "numeric_comparisons": { + "summary": "Compare to a numeric value of a field", + "description": "Compare a field value against a numeric value of a field", + "value": "$.duration_hours ? (@ < 2)" + }, + "array_operations": { + "summary": "Check if an array contains a certain value", + "description": "Check if an array contains a certain value", + "value": "$.tags[*] ? (@ == \"production\")" + }, + "complex_filters": { + "summary": "Combine multiple checks", + "description": "Combine multiple checks", + "value": "$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)" + } + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 5, + "default": 50, + "title": "Page Size" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Sort the items by one or more fields. Use `+` for ascending and `-` for descending order.\n **Available fields:**\n- `item_id`\n- `external_id`\n- `custom_metadata`\n- `terminated_at`\n- `termination_reason`\n\n**Examples:**\n- `?sort=item_id` - Sort by id of the item (ascending)\n- `?sort=-external_id` - Sort by external ID (descending)\n- `?sort=custom_metadata&sort=-external_id` - Sort by metadata, then by external ID (descending)", + "title": "Sort" + }, + "description": "Sort the items by one or more fields. Use `+` for ascending and `-` for descending order.\n **Available fields:**\n- `item_id`\n- `external_id`\n- `custom_metadata`\n- `terminated_at`\n- `termination_reason`\n\n**Examples:**\n- `?sort=item_id` - Sort by id of the item (ascending)\n- `?sort=-external_id` - Sort by external ID (descending)\n- `?sort=custom_metadata&sort=-external_id` - Sort by metadata, then by external ID (descending)" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemResultReadResponse" + }, + "title": "Response List Run Items V1 Runs Run Id Items Get" + } + } + } + }, + "404": { + "description": "Run not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/items/{external_id}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get Item By Run", + "description": "Retrieve details of a specific item (slide) by its external ID and the run ID.", + "operationId": "get_item_by_run_v1_runs__run_id__items__external_id__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "The run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "The run id, returned by `POST /runs/` endpoint" + }, + { + "name": "external_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The `external_id` that was defined for the item by the customer that triggered the run.", + "title": "External Id" + }, + "description": "The `external_id` that was defined for the item by the customer that triggered the run." + }, + { + "name": "share_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Share token for accessing shared runs", + "title": "Share Token" + }, + "description": "Share token for accessing shared runs" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemResultReadResponse" + } + } + } + }, + "404": { + "description": "Not Found - Item with given ID does not exist" + }, + "403": { + "description": "Forbidden - You don't have permission to see this item" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/artifacts/{artifact_id}/file": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get Artifact Url", + "description": "Download the artifact file with the specified artifact_id, belonging to the specified run.\nThe artifact_is is returned by the `GET /v1/runs/{run_id}/items` endpoint as part of the item results, and can also\nbe retrieved via `GET /v1/runs/{run_id}/items/{external_id}`.\n\nThe endpoint may return a redirect response with a presigned URL to download the artifact file from the storage\nbucket. The presigned URL is valid for a limited time, so it should be used immediately after receiving the response.", + "operationId": "get_artifact_url_v1_runs__run_id__artifacts__artifact_id__file_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /runs/` endpoint" + }, + { + "name": "artifact_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "The artifact id to download", + "title": "Artifact Id" + }, + "description": "The artifact id to download" + }, + { + "name": "share_token", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Share token for accessing shared runs", + "title": "Share Token" + }, + "description": "Share token for accessing shared runs" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not Found - Artifact not found for the specified run" + }, + "307": { + "description": "Temporary Redirect - Redirect to the artifact file URL" + }, + "403": { + "description": "Forbidden - You don't have permission to download this artifact" + }, + "410": { + "description": "Gone - Artifact has been deleted" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/artifacts": { + "delete": { + "tags": [ + "Public" + ], + "summary": "Delete Run Items", + "description": "This endpoint allows the caller to explicitly delete artifacts generated by a run.\nIt can only be invoked when the run has reached a final state, i.e.\n`PROCESSED`, `CANCELED_SYSTEM`, or `CANCELED_USER`.\nNote that by default, all artifacts are automatically deleted 30 days after the run finishes,\nregardless of whether the caller explicitly requests such deletion.", + "operationId": "delete_run_items_v1_runs__run_id__artifacts_delete", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /runs/` endpoint" + } + ], + "responses": { + "200": { + "description": "Run artifacts deleted", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Run not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/custom-metadata": { + "put": { + "tags": [ + "Public" + ], + "summary": "Put Run Custom Metadata", + "description": "Update the custom metadata of a run with the specified `run_id`.\n\nOptionally, a checksum may be provided along the custom metadata JSON.\nIt can be used to verify if the custom metadata was updated since the last time it was accessed.\nIf the checksum is provided, it must match the existing custom metadata in the system, ensuring that the current\ncustom metadata value to be overwritten is acknowledged by the user.\nIf no checksum is provided, submitted metadata directly overwrites the existing metadata, without any checks.\n\nThe latest custom metadata and checksum can be retrieved for the run via the `GET /v1/runs/{run_id}` endpoint.\n\n**Note on deadlines:** Run deadlines must be set during run creation and cannot be modified afterward.\nAny deadline changes in custom metadata will be ignored by the system.", + "operationId": "put_run_custom_metadata_v1_runs__run_id__custom_metadata_put", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "Run id, returned by `POST /runs/` endpoint" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomMetadataUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Custom metadata successfully updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomMetadataUpdateResponse" + } + } + } + }, + "404": { + "description": "Run not found" + }, + "403": { + "description": "Forbidden - You don't have permission to update this run" + }, + "412": { + "description": "Precondition Failed - Checksum mismatch, resource has been modified" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/runs/{run_id}/items/{external_id}/custom-metadata": { + "put": { + "tags": [ + "Public" + ], + "summary": "Put Item Custom Metadata By Run", + "description": "Update the custom metadata of the item with the specified `external_id`, belonging to the specified run.\n\nOptionally, a checksum may be provided along the custom metadata JSON.\nIt can be used to verify if the custom metadata was updated since the last time it was accessed.\nIf the checksum is provided, it must match the existing custom metadata in the system, ensuring that the current\ncustom metadata value to be overwritten is acknowledged by the user.\nIf no checksum is provided, submitted metadata directly overwrites the existing metadata, without any checks.\n\nThe latest custom metadata and checksum can be retrieved\n for individual items via `GET /v1/runs/{run_id}/items/{external_id}`,\n and for all items of a run via `GET /v1/runs/{run_id}/items`.", + "operationId": "put_item_custom_metadata_by_run_v1_runs__run_id__items__external_id__custom_metadata_put", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "The run id, returned by `POST /runs/` endpoint", + "title": "Run Id" + }, + "description": "The run id, returned by `POST /runs/` endpoint" + }, + { + "name": "external_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The `external_id` that was defined for the item by the customer that triggered the run.", + "title": "External Id" + }, + "description": "The `external_id` that was defined for the item by the customer that triggered the run." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomMetadataUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Custom metadata successfully updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomMetadataUpdateResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to update this item" + }, + "404": { + "description": "Item not found" + }, + "412": { + "description": "Precondition Failed - Checksum mismatch" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/me": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get current user", + "description": "Retrieves your identity details, including name, email, and organization.\nThis is useful for verifying that the request is being made under the correct user profile\nand organization context, as well as confirming that the expected environment variables are correctly set\n(in case you are using Python SDK)", + "operationId": "get_me_v1_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeReadResponse" + } + } + } + } + }, + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ] + } + }, + "/v1/applications/{application_id}/versions/{version}/documents": { + "get": { + "tags": [ + "Public" + ], + "summary": "List version documents", + "description": "List public documents attached to an application version.\n\nReturns only documents with ``visibility=public`` and ``status=uploaded``.", + "operationId": "list_version_documents", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionDocumentResponse" + }, + "title": "Response List Version Documents" + } + } + } + }, + "404": { + "description": "Application version not found or not accessible" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/applications/{application_id}/versions/{version}/documents/{name}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get version document metadata", + "description": "Return metadata for a single public document attached to an application version.", + "operationId": "get_version_document", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Version" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionDocumentResponse" + } + } + } + }, + "404": { + "description": "Document not found, not public, or version not accessible" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/applications/{application_id}/versions/{version}/documents/{name}/file": { + "get": { + "tags": [ + "Public" + ], + "summary": "Download version document (browser)", + "description": "307 redirect to a short-lived GCS signed URL for downloading a document.\n\nThe signed URL includes ``response-content-disposition=attachment; filename=\"\"``\nso browsers prompt a save-as dialog rather than rendering inline.\nResponse carries ``Cache-Control: no-store``.", + "operationId": "get_version_document_file", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Version" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } + ], + "responses": { + "307": { + "description": "Temporary redirect to signed GCS URL with Content-Disposition: attachment" + }, + "404": { + "description": "Document not found, not public, or version not accessible" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/applications/{application_id}/versions/{version}/documents/{name}/content": { + "get": { + "tags": [ + "Public" + ], + "summary": "Stream version document content (programmatic)", + "description": "307 redirect to a short-lived GCS signed URL for streaming document content.\n\nUnlike ``/file``, no ``Content-Disposition`` override is set — GCS serves\nthe object body with its stored ``Content-Type``. Intended for programmatic\nclients that follow redirects and consume the content directly.\nResponse carries ``Cache-Control: no-store``.", + "operationId": "get_version_document_content", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "application_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Application Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Version" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } + ], + "responses": { + "307": { + "description": "Temporary redirect to signed GCS URL; GCS serves the object with its stored Content-Type" + }, + "404": { + "description": "Document not found, not public, or version not accessible" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/access/grants": { + "post": { + "tags": [ + "Public" + ], + "summary": "Create Grant", + "description": "Create a grant to share access to a resource with a subject (user or organization).", + "operationId": "create_grant_v1_access_grants_post", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to grant access to this resource" + }, + "404": { + "description": "Resource not found" + }, + "422": { + "description": "Unprocessable Entity - Only viewer grants can be created" + } + } + }, + "get": { + "tags": [ + "Public" + ], + "summary": "List Grants", + "description": "List grants.\n\nOrg admins see all grants for all resources in their organization.\nRegular users see grants for all resources they submitted.", + "operationId": "list_grants_v1_access_grants_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "resource_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceType" + }, + { + "type": "null" + } + ], + "title": "Resource Type" + } + }, + { + "name": "resource_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Resource Id" + } + }, + { + "name": "subject_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectType" + }, + { + "type": "null" + } + ], + "title": "Subject Type" + } + }, + { + "name": "subject_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Id" + } + }, + { + "name": "relation", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantRelation" + } + }, + { + "type": "null" + } + ], + "description": "Filter grants by relation type. Can be specified multiple times.", + "title": "Relation" + }, + "description": "Filter grants by relation type. Can be specified multiple times." + }, + { + "name": "revoked", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Revoked" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 5, + "default": 50, + "title": "Page Size" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.", + "title": "Sort" + }, + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantReadResponse" + }, + "title": "Response List Grants V1 Access Grants Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/access/grants/{grant_id}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get Grant", + "description": "Get a grant by its ID.", + "operationId": "get_grant_v1_access_grants__grant_id__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "grant_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Grant ID", + "title": "Grant Id" + }, + "description": "Grant ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to view this grant" + }, + "404": { + "description": "Grant not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Public" + ], + "summary": "Revoke Grant", + "description": "Revoke a grant by its ID. Sets the revoked_at timestamp on the grant.", + "operationId": "revoke_grant_v1_access_grants__grant_id__delete", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "grant_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Grant ID", + "title": "Grant Id" + }, + "description": "Grant ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to revoke this grant" + }, + "404": { + "description": "Grant not found" + }, + "409": { + "description": "Conflict - Grant is already revoked" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/access/share-tokens": { + "post": { + "tags": [ + "Public" + ], + "summary": "Create Share Token", + "description": "Create a share token. The returned share_token value is shown only once and is never stored.\nUse POST /access/grants with subject_type=share_token to grant access to a resource.", + "operationId": "create_share_token_v1_access_share_tokens_post", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareTokenCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareTokenCreateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Public" + ], + "summary": "List Share Tokens", + "description": "List share tokens. Service and Superadmin see all tokens; other users see only their own.", + "operationId": "list_share_tokens_v1_access_share_tokens_get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "Filter by run ID", + "title": "Run Id" + }, + "description": "Filter by run ID" + }, + { + "name": "created_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by share token creator", + "title": "Created By" + }, + "description": "Filter by share token creator" + }, + { + "name": "revoked", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Revoked" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 5, + "default": 50, + "title": "Page Size" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order.", + "title": "Sort" + }, + "description": "Sort the results by one or more fields. Use `+` for ascending and `-` for descending order." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareTokenReadResponse" + }, + "title": "Response List Share Tokens V1 Access Share Tokens Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/access/share-tokens/{share_token_id}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get Share Token", + "description": "Get a share token by its ID.", + "operationId": "get_share_token_v1_access_share_tokens__share_token_id__get", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "share_token_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Share token ID", + "title": "Share Token Id" + }, + "description": "Share token ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareTokenReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to view this share token" + }, + "404": { + "description": "Share token not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Public" + ], + "summary": "Revoke Share Token", + "description": "Revoke a share token by its ID. Invalidates the credential regardless of any active grants.", + "operationId": "revoke_share_token_v1_access_share_tokens__share_token_id__delete", + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + } + ], + "parameters": [ + { + "name": "share_token_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "description": "Share token ID", + "title": "Share Token Id" + }, + "description": "Share token ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareTokenReadResponse" + } + } + } + }, + "403": { + "description": "Forbidden - You don't have permission to revoke this share token" + }, + "404": { + "description": "Share token not found" + }, + "409": { + "description": "Conflict - Share token is already revoked" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApplicationReadResponse": { + "properties": { + "application_id": { + "type": "string", + "title": "Application Id", + "description": "Application ID", + "examples": [ + "he-tme" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Application display name", + "examples": [ + "Atlas H&E-TME" + ] + }, + "regulatory_classes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Regulatory Classes", + "description": "Regulatory classes, to which the applications comply with. Possible values include: RUO, IVDR, FDA.", + "examples": [ + [ + "RUO" + ] + ] + }, + "description": { + "type": "string", + "title": "Description", + "description": "Describing what the application can do ", + "examples": [ + "The Atlas H&E TME is an AI application designed to examine FFPE (formalin-fixed, paraffin-embedded) tissues stained with H&E (hematoxylin and eosin), delivering comprehensive insights into the tumor microenvironment." + ] + }, + "versions": { + "items": { + "$ref": "#/components/schemas/ApplicationVersion" + }, + "type": "array", + "title": "Versions", + "description": "All version numbers available to the user" + } + }, + "type": "object", + "required": [ + "application_id", + "name", + "regulatory_classes", + "description", + "versions" + ], + "title": "ApplicationReadResponse", + "description": "Response schema for `List available applications` and `Read Application by Id` endpoints" + }, + "ApplicationReadShortResponse": { + "properties": { + "application_id": { + "type": "string", + "title": "Application Id", + "description": "Application ID", + "examples": [ + "he-tme" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Application display name", + "examples": [ + "Atlas H&E-TME" + ] + }, + "regulatory_classes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Regulatory Classes", + "description": "Regulatory classes, to which the applications comply with. Possible values include: RUO, IVDR, FDA.", + "examples": [ + [ + "RUO" + ] + ] + }, + "description": { + "type": "string", + "title": "Description", + "description": "Describing what the application can do ", + "examples": [ + "The Atlas H&E TME is an AI application designed to examine FFPE (formalin-fixed, paraffin-embedded) tissues stained with H&E (hematoxylin and eosin), delivering comprehensive insights into the tumor microenvironment." + ] + }, + "latest_version": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApplicationVersion" + }, + { + "type": "null" + } + ], + "description": "The version with highest version number available to the user" + } + }, + "type": "object", + "required": [ + "application_id", + "name", + "regulatory_classes", + "description" + ], + "title": "ApplicationReadShortResponse", + "description": "Response schema for `List available applications` and `Read Application by Id` endpoints" + }, + "ApplicationVersion": { + "properties": { + "number": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "title": "Number", + "description": "The number of the latest version", + "examples": [ + "1.0.0" + ] + }, + "released_at": { + "type": "string", + "format": "date-time", + "title": "Released At", + "description": "The timestamp for when the application version was made available in the Platform", + "examples": [ + "2025-09-15T10:30:45.123Z" + ] + } + }, + "type": "object", + "required": [ + "number", + "released_at" + ], + "title": "ApplicationVersion" + }, + "ArtifactOutput": { + "type": "string", + "enum": [ + "NONE", + "AVAILABLE", + "DELETED_BY_USER", + "DELETED_BY_SYSTEM" + ], + "title": "ArtifactOutput" + }, + "ArtifactState": { + "type": "string", + "enum": [ + "PENDING", + "PROCESSING", + "TERMINATED" + ], + "title": "ArtifactState" + }, + "ArtifactTerminationReason": { + "type": "string", + "enum": [ + "SUCCEEDED", + "USER_ERROR", + "SYSTEM_ERROR", + "SKIPPED" + ], + "title": "ArtifactTerminationReason" + }, + "CustomMetadataUpdateRequest": { + "properties": { + "custom_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata", + "description": "JSON metadata that should be set for the run", + "examples": [ + { + "department": "D1", + "study": "abc-1" + } + ] + }, + "custom_metadata_checksum": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata Checksum", + "description": "Optional field to verify that the latest custom metadata was known. If set to the checksum retrieved via the /runs endpoint, it must match the checksum of the current value in the database.", + "examples": [ + "f54fe109" + ] + } + }, + "type": "object", + "title": "CustomMetadataUpdateRequest" + }, + "CustomMetadataUpdateResponse": { + "properties": { + "custom_metadata_checksum": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata Checksum", + "description": "The checksum of the updated custom metadata. If the `custom_metadata` is None,\nthe checksum also None.", + "readOnly": true + } + }, + "type": "object", + "required": [ + "custom_metadata_checksum" + ], + "title": "CustomMetadataUpdateResponse" + }, + "GrantCreateRequest": { + "properties": { + "resource_type": { + "$ref": "#/components/schemas/ResourceType" + }, + "resource_id": { + "type": "string", + "format": "uuid", + "title": "Resource Id" + }, + "subject_type": { + "$ref": "#/components/schemas/SubjectType" + }, + "subject_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Id" + }, + "subject_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Email" + }, + "relation": { + "$ref": "#/components/schemas/GrantRelation" + } + }, + "type": "object", + "required": [ + "resource_type", + "resource_id", + "subject_type", + "relation" + ], + "title": "GrantCreateRequest" + }, + "GrantReadResponse": { + "properties": { + "grant_id": { + "type": "string", + "format": "uuid", + "title": "Grant Id" + }, + "resource_type": { + "$ref": "#/components/schemas/ResourceType" + }, + "resource_id": { + "type": "string", + "format": "uuid", + "title": "Resource Id" + }, + "subject_type": { + "$ref": "#/components/schemas/SubjectType" + }, + "subject_id": { + "type": "string", + "title": "Subject Id" + }, + "relation": { + "$ref": "#/components/schemas/GrantRelation" + }, + "created_by": { + "type": "string", + "title": "Created By" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "revoked": { + "type": "boolean", + "title": "Revoked" + } + }, + "type": "object", + "required": [ + "grant_id", + "resource_type", + "resource_id", + "subject_type", + "subject_id", + "relation", + "created_by", + "created_at", + "revoked" + ], + "title": "GrantReadResponse" + }, + "GrantRelation": { + "type": "string", + "enum": [ + "owner", + "editor", + "viewer" + ], + "title": "GrantRelation" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "InputArtifact": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "mime_type": { + "type": "string", + "pattern": "^\\w+\\/\\w+[-+.|\\w+]+\\w+$", + "title": "Mime Type", + "examples": [ + "image/tiff" + ] + }, + "metadata_schema": { + "additionalProperties": true, + "type": "object", + "title": "Metadata Schema" + } + }, + "type": "object", + "required": [ + "name", + "mime_type", + "metadata_schema" + ], + "title": "InputArtifact" + }, + "InputArtifactCreationRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Type of artifact. For Atlas H&E-TME, use \"input_slide\"", + "examples": [ + "input_slide" + ] + }, + "download_url": { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri", + "title": "Download Url", + "description": "[Signed URL](https://cloud.google.com/cdn/docs/using-signed-urls) to the input artifact file. The URL should be valid for at least 6 days from the payload submission time.", + "examples": [ + "https://example.com/case-no-1-slide.tiff" + ] + }, + "metadata": { + "additionalProperties": true, + "type": "object", + "title": "Metadata", + "description": "The metadata of the artifact, required by the application version. The JSON schema of the metadata can be requested by `/v1/versions/{application_version_id}`. The schema is located in `input_artifacts.[].metadata_schema`", + "examples": [ + { + "checksum_base64_crc32c": "752f9554", + "height": 2000, + "height_mpp": 0.5, + "width": 10000, + "width_mpp": 0.5 + } + ] + } + }, + "type": "object", + "required": [ + "name", + "download_url", + "metadata" + ], + "title": "InputArtifactCreationRequest", + "description": "Input artifact containing the slide image and associated metadata." + }, + "InputArtifactResultReadResponse": { + "properties": { + "input_artifact_id": { + "type": "string", + "format": "uuid", + "title": "Input Artifact Id", + "description": "The Id of the artifact. Used internally" + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the input from the schema from the `/v1/versions/{version_id}` endpoint.", + "examples": [ + "whole_slide_image" + ] + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata", + "description": "The metadata of the input artifact, provided by the user." + }, + "download_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Download Url", + "description": "The download URL to for the input artifact provided by the user." + } + }, + "type": "object", + "required": [ + "input_artifact_id", + "name" + ], + "title": "InputArtifactResultReadResponse" + }, + "ItemCreationRequest": { + "properties": { + "external_id": { + "type": "string", + "maxLength": 255, + "title": "External Id", + "description": "Unique identifier for this item within the run. Used for referencing items. Must be unique across all items in the same run", + "examples": [ + "slide_1", + "patient_001_slide_A", + "sample_12345" + ] + }, + "custom_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata", + "description": "Optional JSON custom_metadata to store additional information alongside an item.", + "examples": [ + { + "case": "abc" + } + ] + }, + "input_artifacts": { + "items": { + "$ref": "#/components/schemas/InputArtifactCreationRequest" + }, + "type": "array", + "title": "Input Artifacts", + "description": "List of input artifacts for this item. For Atlas H&E-TME, typically contains one artifact (the slide image)", + "examples": [ + [ + { + "download_url": "https://example-bucket.s3.amazonaws.com/slide1.tiff", + "metadata": { + "checksum_base64_crc32c": "64RKKA==", + "height_px": 87761, + "media-type": "image/tiff", + "resolution_mpp": 0.2628238, + "specimen": { + "disease": "LUNG_CANCER", + "tissue": "LUNG" + }, + "staining_method": "H&E", + "width_px": 136223 + }, + "name": "input_slide" + } + ] + ] + } + }, + "type": "object", + "required": [ + "external_id", + "input_artifacts" + ], + "title": "ItemCreationRequest", + "description": "Individual item (slide) to be processed in a run." + }, + "ItemOutput": { + "type": "string", + "enum": [ + "NONE", + "FULL" + ], + "title": "ItemOutput" + }, + "ItemResultReadResponse": { + "properties": { + "item_id": { + "type": "string", + "format": "uuid", + "title": "Item Id", + "description": "Item UUID generated by the Platform" + }, + "external_id": { + "type": "string", + "title": "External Id", + "description": "The external_id of the item from the user payload", + "examples": [ + "slide_1" + ] + }, + "custom_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata", + "description": "The custom_metadata of the item that has been provided by the user on run creation." + }, + "custom_metadata_checksum": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata Checksum", + "description": "The checksum of the `custom_metadata` field.\nCan be used in the `PUT /runs/{run-id}/items/{external_id}/custom_metadata`\nrequest to avoid unwanted override of the values in concurrent requests.", + "examples": [ + "f54fe109" + ] + }, + "queue_position_org": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Queue Position Org", + "description": "The position of the item in the organization's queue." + }, + "queue_position_platform": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Queue Position Platform", + "description": "The position of the item in the platform's queue." + }, + "state": { + "$ref": "#/components/schemas/ItemState", + "description": "\nThe item moves from `PENDING` to `PROCESSING` to `TERMINATED` state.\nWhen terminated, consult the `termination_reason` property to see whether it was successful.\n " + }, + "output": { + "$ref": "#/components/schemas/ItemOutput", + "description": "The output status of the item (NONE, FULL)" + }, + "termination_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/ItemTerminationReason" + }, + { + "type": "null" + } + ], + "description": "\nWhen the `state` is `TERMINATED` this will explain why\n`SUCCEEDED` -> Successful processing.\n`USER_ERROR` -> Failed because the provided input was invalid.\n`SYSTEM_ERROR` -> There was an error in the model or platform.\n`SKIPPED` -> Was cancelled\n" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Code" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message", + "description": "\n The error message in case the `termination_reason` is in `USER_ERROR` or `SYSTEM_ERROR`\n ", + "examples": [ + "This item was not processed because the threshold of 3 items finishing in error state (user or system error) was reached before the item was processed.", + "The item was not processed because the run was cancelled by the user before the item was processed.", + "User error raised by Application because the input data provided by the user cannot be processed:\nThe image width is 123000 px, but the maximum width is 100000 px", + "A system error occurred during the item execution:\n System went out of memory in cell classification", + "An unknown system error occurred during the item execution" + ] + }, + "terminated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Terminated At", + "description": "Timestamp showing when the item reached a terminal state.", + "examples": [ + "2024-01-15T10:30:45.123Z" + ] + }, + "input_artifacts": { + "items": { + "$ref": "#/components/schemas/InputArtifactResultReadResponse" + }, + "type": "array", + "title": "Input Artifacts", + "description": "\nThe input artifact(s) provided by the user. For most applications, this will be one artifact that\ndefines the whole slide image to be processed.\n " + }, + "output_artifacts": { + "items": { + "$ref": "#/components/schemas/OutputArtifactResultReadResponse" + }, + "type": "array", + "title": "Output Artifacts", + "description": "\nThe list of the results generated by the application algorithm. The number of files and their\ntypes depend on the particular application version, call `/v1/versions/{version_id}` to get\nthe details.\n " + } + }, + "type": "object", + "required": [ + "item_id", + "external_id", + "custom_metadata", + "state", + "output", + "input_artifacts", + "output_artifacts" + ], + "title": "ItemResultReadResponse", + "description": "Response schema for items in `List Run Items` endpoint" + }, + "ItemState": { + "type": "string", + "enum": [ + "PENDING", + "PROCESSING", + "TERMINATED" + ], + "title": "ItemState" + }, + "ItemTerminationReason": { + "type": "string", + "enum": [ + "SUCCEEDED", + "USER_ERROR", + "SYSTEM_ERROR", + "SKIPPED" + ], + "title": "ItemTerminationReason" + }, + "MeReadResponse": { + "properties": { + "user": { + "$ref": "#/components/schemas/UserReadResponse" + }, + "organization": { + "$ref": "#/components/schemas/OrganizationReadResponse" + } + }, + "type": "object", + "required": [ + "user", + "organization" + ], + "title": "MeReadResponse", + "description": "Response schema for `Get current user` endpoint" + }, + "OrganizationReadResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Unique organization identifier", + "examples": [ + "org_123456" + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "Organization name (E.g. “aignx”)", + "examples": [ + "aignx" + ] + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name", + "description": "Public organization name (E.g. “Aignostics GmbH”)", + "examples": [ + "Aignostics GmbH" + ] + }, + "aignostics_bucket_hmac_access_key_id": { + "type": "string", + "title": "Aignostics Bucket Hmac Access Key Id", + "description": "HMAC access key ID for the Aignostics-provided storage bucket. Used to authenticate requests for uploading files and generating signed URLs", + "examples": [ + "YOUR_HMAC_ACCESS_KEY_ID" + ] + }, + "aignostics_bucket_hmac_secret_access_key": { + "type": "string", + "title": "Aignostics Bucket Hmac Secret Access Key", + "description": "HMAC secret access key paired with the access key ID. Keep this credential secure.", + "examples": [ + "YOUR/HMAC/SECRET_ACCESS_KEY" + ] + }, + "aignostics_bucket_name": { + "type": "string", + "title": "Aignostics Bucket Name", + "description": "Name of the bucket provided by Aignostics for storing input artifacts (slide images)", + "examples": [ + "aignostics-platform-bucket" + ] + }, + "aignostics_bucket_protocol": { + "type": "string", + "title": "Aignostics Bucket Protocol", + "description": "Protocol to use for bucket access. Defines the URL scheme for connecting to the storage service", + "examples": [ + "gs" + ] + }, + "aignostics_logfire_token": { + "type": "string", + "title": "Aignostics Logfire Token", + "description": "Authentication token for Logfire observability service. Enables sending application logs and performance metrics to Aignostics for monitoring and support", + "examples": [ + "your-logfire-token" + ] + }, + "aignostics_sentry_dsn": { + "type": "string", + "title": "Aignostics Sentry Dsn", + "description": "Data Source Name (DSN) for Sentry error tracking service. Allows automatic reporting of errors and exceptions to Aignostics support team", + "examples": [ + "https://2354s3#ewsha@o44.ingest.us.sentry.io/34345123432" + ] + } + }, + "type": "object", + "required": [ + "id", + "aignostics_bucket_hmac_access_key_id", + "aignostics_bucket_hmac_secret_access_key", + "aignostics_bucket_name", + "aignostics_bucket_protocol", + "aignostics_logfire_token", + "aignostics_sentry_dsn" + ], + "title": "OrganizationReadResponse", + "description": "Part of response schema for Organization object in `Get current user` endpoint.\nThis model corresponds to the response schema returned from\nAuth0 GET /v2/organizations/{id} endpoint, flattens out the metadata out\nand doesn't return branding or token_quota objects.\nFor details, see:\nhttps://auth0.com/docs/api/management/v2/organizations/get-organizations-by-id\n\n#### Configuration for integrating with Aignostics Platform services.\n\nThe Aignostics Platform API requires signed URLs for input artifacts (slide images). To simplify this process,\nAignostics provides a dedicated storage bucket. The HMAC credentials below grant read and write\naccess to this bucket, allowing you to upload files and generate the signed URLs needed for API calls.\n\nAdditionally, logging and error reporting tokens enable Aignostics to provide better support and monitor\nsystem performance for your integration." + }, + "OutputArtifact": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "mime_type": { + "type": "string", + "pattern": "^\\w+\\/\\w+[-+.|\\w+]+\\w+$", + "title": "Mime Type", + "examples": [ + "application/vnd.apache.parquet" + ] + }, + "metadata_schema": { + "additionalProperties": true, + "type": "object", + "title": "Metadata Schema" + }, + "scope": { + "$ref": "#/components/schemas/OutputArtifactScope" + }, + "visibility": { + "$ref": "#/components/schemas/OutputArtifactVisibility" + } + }, + "type": "object", + "required": [ + "name", + "mime_type", + "metadata_schema", + "scope", + "visibility" + ], + "title": "OutputArtifact" + }, + "OutputArtifactResultReadResponse": { + "properties": { + "output_artifact_id": { + "type": "string", + "format": "uuid", + "title": "Output Artifact Id", + "description": "The Id of the artifact. Used internally" + }, + "name": { + "type": "string", + "title": "Name", + "description": "\nName of the output from the output schema from the `/v1/versions/{version_id}` endpoint.\n ", + "examples": [ + "tissue_qc:tiff_heatmap" + ] + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata", + "description": "The metadata of the output artifact, provided by the application. Can only be None if the artifact itself was deleted." + }, + "state": { + "$ref": "#/components/schemas/ArtifactState", + "description": "The current state of the artifact (PENDING, PROCESSING, TERMINATED)" + }, + "termination_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArtifactTerminationReason" + }, + { + "type": "null" + } + ], + "description": "The reason for termination when state is TERMINATED" + }, + "output": { + "$ref": "#/components/schemas/ArtifactOutput", + "description": "The output status of the artifact (NONE, FULL)" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Code" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + }, + "download_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Download Url", + "description": "\nThe download URL to the output file. The URL is valid for 1 hour after the endpoint is called.\nA new URL is generated every time the endpoint is called.\n ", + "deprecated": true + } + }, + "type": "object", + "required": [ + "output_artifact_id", + "name", + "state", + "output" + ], + "title": "OutputArtifactResultReadResponse" + }, + "OutputArtifactScope": { + "type": "string", + "enum": [ + "ITEM", + "GLOBAL" + ], + "title": "OutputArtifactScope" + }, + "OutputArtifactVisibility": { + "type": "string", + "enum": [ + "INTERNAL", + "EXTERNAL" + ], + "title": "OutputArtifactVisibility" + }, + "ResourceType": { + "type": "string", + "enum": [ + "run", + "item", + "output_artifact", + "share_token" + ], + "title": "ResourceType" + }, + "RunCreationRequest": { + "properties": { + "application_id": { + "type": "string", + "title": "Application Id", + "description": "Unique ID for the application to use for processing", + "examples": [ + "he-tme" + ] + }, + "version_number": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version Number", + "description": "Semantic version of the application to use for processing. If not provided, the latest available version will be used", + "examples": [ + "1.0.0-beta1" + ] + }, + "custom_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata", + "description": "Optional JSON metadata to store additional information alongside the run", + "examples": [ + { + "department": "D1", + "study": "abc-1" + } + ] + }, + "scheduling": { + "anyOf": [ + { + "$ref": "#/components/schemas/SchedulingRequest" + }, + { + "type": "null" + } + ], + "description": "Optional scheduling constraints for this run.", + "examples": [ + { + "deadline": "2026-03-05T23:59:59Z", + "due_date": "2026-03-04T23:59:59Z" + } + ] + }, + "callback_context": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Callback Context", + "description": "Opaque JSON object for caller-supplied correlation data. Stored verbatim and echoed in state-change events. Max 1 KB after JSON serialization." + }, + "items": { + "items": { + "$ref": "#/components/schemas/ItemCreationRequest" + }, + "type": "array", + "minItems": 1, + "title": "Items", + "description": "List of items (slides) to process. Each item represents a whole slide image (WSI) with its associated metadata and artifacts", + "examples": [ + [ + { + "external_id": "slide_1", + "input_artifacts": [ + { + "download_url": "https://example-bucket.s3.amazonaws.com/slide1.tiff?signature=...", + "metadata": { + "checksum_base64_crc32c": "64RKKA==", + "height_px": 87761, + "media-type": "image/tiff", + "resolution_mpp": 0.2628238, + "specimen": { + "disease": "LUNG_CANCER", + "tissue": "LUNG" + }, + "staining_method": "H&E", + "width_px": 136223 + }, + "name": "input_slide" + } + ] + } + ] + ] + } + }, + "type": "object", + "required": [ + "application_id", + "items" + ], + "title": "RunCreationRequest", + "description": "Request schema for `Initiate Run` endpoint.\nIt describes which application version is chosen, and which user data should be processed." + }, + "RunCreationResponse": { + "properties": { + "run_id": { + "type": "string", + "format": "uuid", + "title": "Run Id", + "examples": [ + "3fa85f64-5717-4562-b3fc-2c963f66afa6" + ] + } + }, + "type": "object", + "required": [ + "run_id" + ], + "title": "RunCreationResponse" + }, + "RunItemStatistics": { + "properties": { + "item_count": { + "type": "integer", + "title": "Item Count", + "description": "Total number of the items in the run" + }, + "item_pending_count": { + "type": "integer", + "title": "Item Pending Count", + "description": "The number of items in `PENDING` state" + }, + "item_processing_count": { + "type": "integer", + "title": "Item Processing Count", + "description": "The number of items in `PROCESSING` state" + }, + "item_user_error_count": { + "type": "integer", + "title": "Item User Error Count", + "description": "The number of items in `TERMINATED` state, and the item termination reason is `USER_ERROR`" + }, + "item_system_error_count": { + "type": "integer", + "title": "Item System Error Count", + "description": "The number of items in `TERMINATED` state, and the item termination reason is `SYSTEM_ERROR`" + }, + "item_skipped_count": { + "type": "integer", + "title": "Item Skipped Count", + "description": "The number of items in `TERMINATED` state, and the item termination reason is `SKIPPED`" + }, + "item_succeeded_count": { + "type": "integer", + "title": "Item Succeeded Count", + "description": "The number of items in `TERMINATED` state, and the item termination reason is `SUCCEEDED`" + } + }, + "type": "object", + "required": [ + "item_count", + "item_pending_count", + "item_processing_count", + "item_user_error_count", + "item_system_error_count", + "item_skipped_count", + "item_succeeded_count" + ], + "title": "RunItemStatistics" + }, + "RunOutput": { + "type": "string", + "enum": [ + "NONE", + "PARTIAL", + "FULL" + ], + "title": "RunOutput" + }, + "RunReadResponse": { + "properties": { + "run_id": { + "type": "string", + "format": "uuid", + "title": "Run Id", + "description": "UUID of the application" + }, + "application_id": { + "type": "string", + "title": "Application Id", + "description": "Application id", + "examples": [ + "he-tme" + ] + }, + "version_number": { + "type": "string", + "title": "Version Number", + "description": "Application version number", + "examples": [ + "0.4.4" + ] + }, + "state": { + "$ref": "#/components/schemas/RunState", + "description": "When the run request is received by the Platform, the `state` of it is set to\n`PENDING`. The state changes to `PROCESSING` when at least one item is being processed. After `PROCESSING`, the\nstate of the run can switch back to `PENDING` if there are no processing items, or to `TERMINATED` when the run\nfinished processing." + }, + "output": { + "$ref": "#/components/schemas/RunOutput", + "description": "The status of the output of the run. When 0 items are successfully processed the output is\n`NONE`, after one item is successfully processed, the value is set to `PARTIAL`. When all items of the run are\nsuccessfully processed, the output is set to `FULL`." + }, + "termination_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/RunTerminationReason" + }, + { + "type": "null" + } + ], + "description": "The termination reason of the run. When the run is not in `TERMINATED` state, the\n termination_reason is `null`. If all items of of the run are processed (successfully or with an error), then\n termination_reason is set to `ALL_ITEMS_PROCESSED`. If the run is cancelled by the user, the value is set to\n `CANCELED_BY_USER`. If the run reaches the threshold of number of failed items, the Platform cancels the run\n and sets the termination_reason to `CANCELED_BY_SYSTEM`.\n " + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Code", + "description": "When the termination_reason is set to CANCELED_BY_SYSTEM, the error_code is set to define the\n structured description of the error.", + "examples": [ + "SCHEDULER.ITEMS_WITH_ERROR_THRESHOLD_REACHED" + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message", + "description": "When the termination_reason is set to CANCELED_BY_SYSTEM, the error_message is set to provide\n more insights to the error cause.", + "examples": [ + "Run canceled given errors on more than 10 items." + ] + }, + "statistics": { + "$ref": "#/components/schemas/RunItemStatistics", + "description": "Aggregated statistics of the run execution" + }, + "custom_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata", + "description": "Optional JSON metadata that was stored in alongside the run by the user", + "examples": [ + { + "department": "D1", + "study": "abc-1" + } + ] + }, + "custom_metadata_checksum": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Custom Metadata Checksum", + "description": "The checksum of the `custom_metadata` field. Can be used in the `PUT /runs/{run-id}/custom_metadata`\nrequest to avoid unwanted override of the values in concurrent requests.", + "examples": [ + "f54fe109" + ] + }, + "submitted_at": { + "type": "string", + "format": "date-time", + "title": "Submitted At", + "description": "Timestamp showing when the run was triggered" + }, + "submitted_by": { + "type": "string", + "title": "Submitted By", + "description": "Id of the user who triggered the run", + "examples": [ + "auth0|123456" + ] + }, + "organization_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Uniquely identifies the organization the Run was created for" + }, + "terminated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Terminated At", + "description": "Timestamp showing when the run reached a terminal state.", + "examples": [ + "2024-01-15T10:30:45.123Z" + ] + }, + "num_preceding_items_org": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Num Preceding Items Org", + "description": "How many Items from other Runs in the same Organization are due to begin processing before this Run's next Item does." + }, + "num_preceding_items_platform": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Num Preceding Items Platform", + "description": "How many Items from other Runs are due to begin processing before this Run's next Item does." + }, + "scheduling": { + "anyOf": [ + { + "$ref": "#/components/schemas/SchedulingResponse" + }, + { + "type": "null" + } + ], + "description": "Scheduling constraints set for this run." + } + }, + "type": "object", + "required": [ + "run_id", + "application_id", + "version_number", + "state", + "output", + "termination_reason", + "error_code", + "error_message", + "statistics", + "submitted_at", + "submitted_by" + ], + "title": "RunReadResponse", + "description": "Response schema for `Get run details` endpoint" + }, + "RunState": { + "type": "string", + "enum": [ + "PENDING", + "PROCESSING", + "TERMINATED" + ], + "title": "RunState" + }, + "RunTerminationReason": { + "type": "string", + "enum": [ + "ALL_ITEMS_PROCESSED", + "CANCELED_BY_SYSTEM", + "CANCELED_BY_USER" + ], + "title": "RunTerminationReason" + }, + "SchedulingRequest": { + "properties": { + "due_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Due Date", + "description": "Requested completion time. Items are prioritized to meet this target.", + "examples": [ + "2026-03-04T23:59:59Z" + ] + }, + "deadline": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Deadline", + "description": "Hard deadline. The run will be cancelled if not completed by this time.", + "examples": [ + "2026-03-05T23:59:59Z" + ] + } + }, + "type": "object", + "title": "SchedulingRequest", + "description": "Scheduling constraints for a run." + }, + "SchedulingResponse": { + "properties": { + "due_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Due Date" + }, + "deadline": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Deadline" + } + }, + "type": "object", + "title": "SchedulingResponse", + "description": "Scheduling fields returned in run responses." + }, + "ShareTokenCreateRequest": { + "properties": { + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + } + }, + "type": "object", + "title": "ShareTokenCreateRequest" + }, + "ShareTokenCreateResponse": { + "properties": { + "share_token_id": { + "type": "string", + "format": "uuid", + "title": "Share Token Id" + }, + "share_token": { + "type": "string", + "title": "Share Token" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "revoked": { + "type": "boolean", + "title": "Revoked" + } + }, + "type": "object", + "required": [ + "share_token_id", + "share_token", + "created_at", + "expires_at", + "revoked" + ], + "title": "ShareTokenCreateResponse", + "description": "Returned only on POST — includes the one-time share_token." + }, + "ShareTokenReadResponse": { + "properties": { + "share_token_id": { + "type": "string", + "format": "uuid", + "title": "Share Token Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "revoked": { + "type": "boolean", + "title": "Revoked" + } + }, + "type": "object", + "required": [ + "share_token_id", + "created_at", + "expires_at", + "revoked" + ], + "title": "ShareTokenReadResponse", + "description": "Returned on GET endpoints — omits share_token." + }, + "SubjectType": { + "type": "string", + "enum": [ + "user", + "organization_admin", + "organization_user", + "share_token" + ], + "title": "SubjectType" + }, + "UserReadResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Unique user identifier", + "examples": [ + "auth0|123456" + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email", + "description": "User email", + "examples": [ + "user@domain.com" + ] + }, + "email_verified": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Email Verified", + "examples": [ + true + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "First and last name of the user", + "examples": [ + "Jane Doe" + ] + }, + "given_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Given Name", + "examples": [ + "Jane" + ] + }, + "family_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Family Name", + "examples": [ + "Doe" + ] + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname", + "examples": [ + "jdoe" + ] + }, + "picture": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Picture", + "examples": [ + "https://example.com/jdoe.jpg" + ] + }, + "updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Updated At", + "examples": [ + "2023-10-05T14:48:00.000Z" + ] + } + }, + "type": "object", + "required": [ + "id" + ], + "title": "UserReadResponse", + "description": "Part of response schema for User object in `Get current user` endpoint.\nThis model corresponds to the response schema returned from\nAuth0 GET /v2/users/{id} endpoint.\nFor details, see:\nhttps://auth0.com/docs/api/management/v2/users/get-users-by-id" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VersionDocumentResponse": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "mime_type": { + "type": "string", + "title": "Mime Type" + }, + "visibility": { + "$ref": "#/components/schemas/VersionDocumentVisibility" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "mime_type", + "visibility", + "created_at", + "updated_at" + ], + "title": "VersionDocumentResponse" + }, + "VersionDocumentVisibility": { + "type": "string", + "enum": [ + "public", + "internal" + ], + "title": "VersionDocumentVisibility" + }, + "VersionReadResponse": { + "properties": { + "version_number": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "title": "Version Number", + "description": "Semantic version of the application" + }, + "changelog": { + "type": "string", + "title": "Changelog", + "description": "Description of the changes relative to the previous version" + }, + "input_artifacts": { + "items": { + "$ref": "#/components/schemas/InputArtifact" + }, + "type": "array", + "title": "Input Artifacts", + "description": "List of the input fields, provided by the User" + }, + "output_artifacts": { + "items": { + "$ref": "#/components/schemas/OutputArtifact" + }, + "type": "array", + "title": "Output Artifacts", + "description": "List of the output fields, generated by the application" + }, + "released_at": { + "type": "string", + "format": "date-time", + "title": "Released At", + "description": "The timestamp when the application version was registered" + } + }, + "type": "object", + "required": [ + "version_number", + "changelog", + "input_artifacts", + "output_artifacts", + "released_at" + ], + "title": "VersionReadResponse", + "description": "Base Response schema for the `Application Version Details` endpoint" + } + }, + "securitySchemes": { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "scopes": {}, + "authorizationUrl": "https://dev-8ouohmmrbuh2h4vu.eu.auth0.com/authorize", + "tokenUrl": "https://dev-8ouohmmrbuh2h4vu.eu.auth0.com/oauth/token" + } + } + } + } + } +} diff --git a/codegen/in/openapi.json b/codegen/in/openapi.json index 4b3e1dbee..eaa081738 100644 --- a/codegen/in/openapi.json +++ b/codegen/in/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Aignostics Platform API", "description": "\nThe Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. \n\nTo begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. \n\nMore information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com).\n\n**How to authorize and test API endpoints:**\n\n1. Click the \"Authorize\" button in the right corner below\n3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials\n4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint\n\n**Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized.\n\n", - "version": "1.6.0" + "version": "1.6.0+dev.e1f10d7ad5b" }, "servers": [ { @@ -583,6 +583,50 @@ "title": "Page Size" } }, + { + "name": "submitted_by", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user.", + "examples": [ + "me", + "auth0|123456789" + ], + "title": "Submitted By" + }, + "description": "Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user." + }, + { + "name": "organization_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization.", + "examples": [ + "my_org", + "org_acme" + ], + "title": "Organization Id" + }, + "description": "Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization." + }, { "name": "for_organization", "in": "query", @@ -1878,6 +1922,27 @@ "title": "Subject Id" } }, + { + "name": "relation", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantRelation" + } + }, + { + "type": "null" + } + ], + "description": "Filter grants by relation type. Can be specified multiple times.", + "title": "Relation" + }, + "description": "Filter grants by relation type. Can be specified multiple times." + }, { "name": "revoked", "in": "query", @@ -3726,6 +3791,18 @@ "auth0|123456" ] }, + "organization_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Organization Id", + "description": "Uniquely identifies the organization the Run was created for" + }, "terminated_at": { "anyOf": [ { @@ -4269,8 +4346,8 @@ "flows": { "authorizationCode": { "scopes": {}, - "authorizationUrl": "https://aignostics-platform-staging.eu.auth0.com/authorize", - "tokenUrl": "https://aignostics-platform-staging.eu.auth0.com/oauth/token" + "authorizationUrl": "https://dev-8ouohmmrbuh2h4vu.eu.auth0.com/authorize", + "tokenUrl": "https://dev-8ouohmmrbuh2h4vu.eu.auth0.com/oauth/token" } } } diff --git a/codegen/out/aignx/codegen/api/public_api.py b/codegen/out/aignx/codegen/api/public_api.py index ef6ebaf79..712976630 100644 --- a/codegen/out/aignx/codegen/api/public_api.py +++ b/codegen/out/aignx/codegen/api/public_api.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -25,6 +25,7 @@ from aignx.codegen.models.custom_metadata_update_response import CustomMetadataUpdateResponse from aignx.codegen.models.grant_create_request import GrantCreateRequest from aignx.codegen.models.grant_read_response import GrantReadResponse +from aignx.codegen.models.grant_relation import GrantRelation from aignx.codegen.models.item_result_read_response import ItemResultReadResponse from aignx.codegen.models.item_state import ItemState from aignx.codegen.models.item_termination_reason import ItemTerminationReason @@ -4614,6 +4615,7 @@ def list_grants_v1_access_grants_get( resource_id: Optional[StrictStr] = None, subject_type: Optional[SubjectType] = None, subject_id: Optional[StrictStr] = None, + relation: Annotated[Optional[List[GrantRelation]], Field(description="Filter grants by relation type. Can be specified multiple times.")] = None, revoked: Optional[StrictBool] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, @@ -4643,6 +4645,8 @@ def list_grants_v1_access_grants_get( :type subject_type: SubjectType :param subject_id: :type subject_id: str + :param relation: Filter grants by relation type. Can be specified multiple times. + :type relation: List[GrantRelation] :param revoked: :type revoked: bool :param page: @@ -4678,6 +4682,7 @@ def list_grants_v1_access_grants_get( resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, + relation=relation, revoked=revoked, page=page, page_size=page_size, @@ -4710,6 +4715,7 @@ def list_grants_v1_access_grants_get_with_http_info( resource_id: Optional[StrictStr] = None, subject_type: Optional[SubjectType] = None, subject_id: Optional[StrictStr] = None, + relation: Annotated[Optional[List[GrantRelation]], Field(description="Filter grants by relation type. Can be specified multiple times.")] = None, revoked: Optional[StrictBool] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, @@ -4739,6 +4745,8 @@ def list_grants_v1_access_grants_get_with_http_info( :type subject_type: SubjectType :param subject_id: :type subject_id: str + :param relation: Filter grants by relation type. Can be specified multiple times. + :type relation: List[GrantRelation] :param revoked: :type revoked: bool :param page: @@ -4774,6 +4782,7 @@ def list_grants_v1_access_grants_get_with_http_info( resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, + relation=relation, revoked=revoked, page=page, page_size=page_size, @@ -4806,6 +4815,7 @@ def list_grants_v1_access_grants_get_without_preload_content( resource_id: Optional[StrictStr] = None, subject_type: Optional[SubjectType] = None, subject_id: Optional[StrictStr] = None, + relation: Annotated[Optional[List[GrantRelation]], Field(description="Filter grants by relation type. Can be specified multiple times.")] = None, revoked: Optional[StrictBool] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, @@ -4835,6 +4845,8 @@ def list_grants_v1_access_grants_get_without_preload_content( :type subject_type: SubjectType :param subject_id: :type subject_id: str + :param relation: Filter grants by relation type. Can be specified multiple times. + :type relation: List[GrantRelation] :param revoked: :type revoked: bool :param page: @@ -4870,6 +4882,7 @@ def list_grants_v1_access_grants_get_without_preload_content( resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, + relation=relation, revoked=revoked, page=page, page_size=page_size, @@ -4897,6 +4910,7 @@ def _list_grants_v1_access_grants_get_serialize( resource_id, subject_type, subject_id, + relation, revoked, page, page_size, @@ -4910,6 +4924,7 @@ def _list_grants_v1_access_grants_get_serialize( _host = None _collection_formats: Dict[str, str] = { + 'relation': 'multi', 'sort': 'multi', } @@ -4940,6 +4955,10 @@ def _list_grants_v1_access_grants_get_serialize( _query_params.append(('subject_id', subject_id)) + if relation is not None: + + _query_params.append(('relation', relation)) + if revoked is not None: _query_params.append(('revoked', revoked)) @@ -5425,6 +5444,8 @@ def list_runs_v1_runs_get( custom_metadata: Annotated[Optional[Annotated[str, Field(strict=True, max_length=1000)]], Field(description="Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata. #### URL Encoding Required **Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding. #### Examples (Clear Format): - **Field existence**: `$.study` - Runs that have a study field defined - **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value - **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75 - **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\" - **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements #### Examples (URL-Encoded Format): - **Field existence**: `%24.study` - **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)` - **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)` - **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)` - **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)` #### Notes - JSONPath expressions are evaluated using PostgreSQL's `@?` operator - The `$.` prefix is automatically added to root-level field references if missing - String values in conditions must be enclosed in double quotes - Use `&&` for AND operations and `||` for OR operations - Regular expressions use `like_regex` with standard regex syntax - **Please remember to URL-encode the entire JSONPath expression when making HTTP requests** ")] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, + submitted_by: Annotated[Optional[StrictStr], Field(description="Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user.")] = None, + organization_id: Annotated[Optional[StrictStr], Field(description="Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization.")] = None, for_organization: Annotated[Optional[StrictStr], Field(description="Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs.")] = None, sort: Annotated[Optional[List[StrictStr]], Field(description="Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) ")] = None, _request_timeout: Union[ @@ -5456,6 +5477,10 @@ def list_runs_v1_runs_get( :type page: int :param page_size: :type page_size: int + :param submitted_by: Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user. + :type submitted_by: str + :param organization_id: Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization. + :type organization_id: str :param for_organization: Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs. :type for_organization: str :param sort: Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) @@ -5489,6 +5514,8 @@ def list_runs_v1_runs_get( custom_metadata=custom_metadata, page=page, page_size=page_size, + submitted_by=submitted_by, + organization_id=organization_id, for_organization=for_organization, sort=sort, _request_auth=_request_auth, @@ -5522,6 +5549,8 @@ def list_runs_v1_runs_get_with_http_info( custom_metadata: Annotated[Optional[Annotated[str, Field(strict=True, max_length=1000)]], Field(description="Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata. #### URL Encoding Required **Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding. #### Examples (Clear Format): - **Field existence**: `$.study` - Runs that have a study field defined - **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value - **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75 - **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\" - **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements #### Examples (URL-Encoded Format): - **Field existence**: `%24.study` - **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)` - **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)` - **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)` - **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)` #### Notes - JSONPath expressions are evaluated using PostgreSQL's `@?` operator - The `$.` prefix is automatically added to root-level field references if missing - String values in conditions must be enclosed in double quotes - Use `&&` for AND operations and `||` for OR operations - Regular expressions use `like_regex` with standard regex syntax - **Please remember to URL-encode the entire JSONPath expression when making HTTP requests** ")] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, + submitted_by: Annotated[Optional[StrictStr], Field(description="Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user.")] = None, + organization_id: Annotated[Optional[StrictStr], Field(description="Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization.")] = None, for_organization: Annotated[Optional[StrictStr], Field(description="Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs.")] = None, sort: Annotated[Optional[List[StrictStr]], Field(description="Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) ")] = None, _request_timeout: Union[ @@ -5553,6 +5582,10 @@ def list_runs_v1_runs_get_with_http_info( :type page: int :param page_size: :type page_size: int + :param submitted_by: Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user. + :type submitted_by: str + :param organization_id: Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization. + :type organization_id: str :param for_organization: Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs. :type for_organization: str :param sort: Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) @@ -5586,6 +5619,8 @@ def list_runs_v1_runs_get_with_http_info( custom_metadata=custom_metadata, page=page, page_size=page_size, + submitted_by=submitted_by, + organization_id=organization_id, for_organization=for_organization, sort=sort, _request_auth=_request_auth, @@ -5619,6 +5654,8 @@ def list_runs_v1_runs_get_without_preload_content( custom_metadata: Annotated[Optional[Annotated[str, Field(strict=True, max_length=1000)]], Field(description="Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata. #### URL Encoding Required **Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding. #### Examples (Clear Format): - **Field existence**: `$.study` - Runs that have a study field defined - **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value - **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75 - **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\" - **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements #### Examples (URL-Encoded Format): - **Field existence**: `%24.study` - **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)` - **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)` - **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)` - **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)` #### Notes - JSONPath expressions are evaluated using PostgreSQL's `@?` operator - The `$.` prefix is automatically added to root-level field references if missing - String values in conditions must be enclosed in double quotes - Use `&&` for AND operations and `||` for OR operations - Regular expressions use `like_regex` with standard regex syntax - **Please remember to URL-encode the entire JSONPath expression when making HTTP requests** ")] = None, page: Optional[Annotated[int, Field(strict=True, ge=1)]] = None, page_size: Optional[Annotated[int, Field(le=100, strict=True, ge=5)]] = None, + submitted_by: Annotated[Optional[StrictStr], Field(description="Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user.")] = None, + organization_id: Annotated[Optional[StrictStr], Field(description="Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization.")] = None, for_organization: Annotated[Optional[StrictStr], Field(description="Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs.")] = None, sort: Annotated[Optional[List[StrictStr]], Field(description="Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) ")] = None, _request_timeout: Union[ @@ -5650,6 +5687,10 @@ def list_runs_v1_runs_get_without_preload_content( :type page: int :param page_size: :type page_size: int + :param submitted_by: Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user. + :type submitted_by: str + :param organization_id: Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization. + :type organization_id: str :param for_organization: Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs. :type for_organization: str :param sort: Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) @@ -5683,6 +5724,8 @@ def list_runs_v1_runs_get_without_preload_content( custom_metadata=custom_metadata, page=page, page_size=page_size, + submitted_by=submitted_by, + organization_id=organization_id, for_organization=for_organization, sort=sort, _request_auth=_request_auth, @@ -5711,6 +5754,8 @@ def _list_runs_v1_runs_get_serialize( custom_metadata, page, page_size, + submitted_by, + organization_id, for_organization, sort, _request_auth, @@ -5760,6 +5805,14 @@ def _list_runs_v1_runs_get_serialize( _query_params.append(('page_size', page_size)) + if submitted_by is not None: + + _query_params.append(('submitted_by', submitted_by)) + + if organization_id is not None: + + _query_params.append(('organization_id', organization_id)) + if for_organization is not None: _query_params.append(('for_organization', for_organization)) diff --git a/codegen/out/aignx/codegen/api_client.py b/codegen/out/aignx/codegen/api_client.py index cd8b95d27..2e1e654bc 100644 --- a/codegen/out/aignx/codegen/api_client.py +++ b/codegen/out/aignx/codegen/api_client.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/configuration.py b/codegen/out/aignx/codegen/configuration.py index 36a2ec2f8..06388ef59 100644 --- a/codegen/out/aignx/codegen/configuration.py +++ b/codegen/out/aignx/codegen/configuration.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -502,7 +502,7 @@ def to_debug_report(self) -> str: return "Python SDK Debug Report:\n"\ "OS: {env}\n"\ "Python Version: {pyversion}\n"\ - "Version of the API: 1.6.0\n"\ + "Version of the API: 1.6.0+dev.e1f10d7ad5b\n"\ "SDK Package Version: 1.0.0".\ format(env=sys.platform, pyversion=sys.version) diff --git a/codegen/out/aignx/codegen/exceptions.py b/codegen/out/aignx/codegen/exceptions.py index d5ddf1bad..614452737 100644 --- a/codegen/out/aignx/codegen/exceptions.py +++ b/codegen/out/aignx/codegen/exceptions.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/application_read_response.py b/codegen/out/aignx/codegen/models/application_read_response.py index d0836f7b7..9c5108dec 100644 --- a/codegen/out/aignx/codegen/models/application_read_response.py +++ b/codegen/out/aignx/codegen/models/application_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/application_read_short_response.py b/codegen/out/aignx/codegen/models/application_read_short_response.py index f1f01a42a..561fc9faa 100644 --- a/codegen/out/aignx/codegen/models/application_read_short_response.py +++ b/codegen/out/aignx/codegen/models/application_read_short_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/application_version.py b/codegen/out/aignx/codegen/models/application_version.py index 06096d97e..97bced6d7 100644 --- a/codegen/out/aignx/codegen/models/application_version.py +++ b/codegen/out/aignx/codegen/models/application_version.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/artifact_output.py b/codegen/out/aignx/codegen/models/artifact_output.py index 60cd2218d..fbd5cea27 100644 --- a/codegen/out/aignx/codegen/models/artifact_output.py +++ b/codegen/out/aignx/codegen/models/artifact_output.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/artifact_state.py b/codegen/out/aignx/codegen/models/artifact_state.py index d6726b17c..e57c2bd54 100644 --- a/codegen/out/aignx/codegen/models/artifact_state.py +++ b/codegen/out/aignx/codegen/models/artifact_state.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/artifact_termination_reason.py b/codegen/out/aignx/codegen/models/artifact_termination_reason.py index 564ea6639..f6e2f2e9d 100644 --- a/codegen/out/aignx/codegen/models/artifact_termination_reason.py +++ b/codegen/out/aignx/codegen/models/artifact_termination_reason.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/custom_metadata_update_request.py b/codegen/out/aignx/codegen/models/custom_metadata_update_request.py index 0645a7867..e259cb399 100644 --- a/codegen/out/aignx/codegen/models/custom_metadata_update_request.py +++ b/codegen/out/aignx/codegen/models/custom_metadata_update_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/custom_metadata_update_response.py b/codegen/out/aignx/codegen/models/custom_metadata_update_response.py index 6c1427463..c8ef533cb 100644 --- a/codegen/out/aignx/codegen/models/custom_metadata_update_response.py +++ b/codegen/out/aignx/codegen/models/custom_metadata_update_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/grant_create_request.py b/codegen/out/aignx/codegen/models/grant_create_request.py index fd437135b..1d14b9c9f 100644 --- a/codegen/out/aignx/codegen/models/grant_create_request.py +++ b/codegen/out/aignx/codegen/models/grant_create_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/grant_read_response.py b/codegen/out/aignx/codegen/models/grant_read_response.py index f60c05e99..33ec1038a 100644 --- a/codegen/out/aignx/codegen/models/grant_read_response.py +++ b/codegen/out/aignx/codegen/models/grant_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/grant_relation.py b/codegen/out/aignx/codegen/models/grant_relation.py index 58f94a71e..0ad88e189 100644 --- a/codegen/out/aignx/codegen/models/grant_relation.py +++ b/codegen/out/aignx/codegen/models/grant_relation.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/http_validation_error.py b/codegen/out/aignx/codegen/models/http_validation_error.py index 7733008d3..0577d522d 100644 --- a/codegen/out/aignx/codegen/models/http_validation_error.py +++ b/codegen/out/aignx/codegen/models/http_validation_error.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/input_artifact.py b/codegen/out/aignx/codegen/models/input_artifact.py index f97f4141e..5bcca24e0 100644 --- a/codegen/out/aignx/codegen/models/input_artifact.py +++ b/codegen/out/aignx/codegen/models/input_artifact.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/input_artifact_creation_request.py b/codegen/out/aignx/codegen/models/input_artifact_creation_request.py index 93e90992b..5f1f6a61c 100644 --- a/codegen/out/aignx/codegen/models/input_artifact_creation_request.py +++ b/codegen/out/aignx/codegen/models/input_artifact_creation_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/input_artifact_result_read_response.py b/codegen/out/aignx/codegen/models/input_artifact_result_read_response.py index e4d779dcd..e709920fd 100644 --- a/codegen/out/aignx/codegen/models/input_artifact_result_read_response.py +++ b/codegen/out/aignx/codegen/models/input_artifact_result_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/item_creation_request.py b/codegen/out/aignx/codegen/models/item_creation_request.py index c91f837f5..3ea9181ca 100644 --- a/codegen/out/aignx/codegen/models/item_creation_request.py +++ b/codegen/out/aignx/codegen/models/item_creation_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/item_output.py b/codegen/out/aignx/codegen/models/item_output.py index ad5fe184b..30693e294 100644 --- a/codegen/out/aignx/codegen/models/item_output.py +++ b/codegen/out/aignx/codegen/models/item_output.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/item_result_read_response.py b/codegen/out/aignx/codegen/models/item_result_read_response.py index 101f888cf..08f90c31e 100644 --- a/codegen/out/aignx/codegen/models/item_result_read_response.py +++ b/codegen/out/aignx/codegen/models/item_result_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/item_state.py b/codegen/out/aignx/codegen/models/item_state.py index d1ad49ecc..1d8a6fcf5 100644 --- a/codegen/out/aignx/codegen/models/item_state.py +++ b/codegen/out/aignx/codegen/models/item_state.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/item_termination_reason.py b/codegen/out/aignx/codegen/models/item_termination_reason.py index 7fd03bf45..5bc6eb46e 100644 --- a/codegen/out/aignx/codegen/models/item_termination_reason.py +++ b/codegen/out/aignx/codegen/models/item_termination_reason.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/me_read_response.py b/codegen/out/aignx/codegen/models/me_read_response.py index ca44416f8..82cc4a4fe 100644 --- a/codegen/out/aignx/codegen/models/me_read_response.py +++ b/codegen/out/aignx/codegen/models/me_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/organization_read_response.py b/codegen/out/aignx/codegen/models/organization_read_response.py index ff0aa1149..e9dbebc33 100644 --- a/codegen/out/aignx/codegen/models/organization_read_response.py +++ b/codegen/out/aignx/codegen/models/organization_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/output_artifact.py b/codegen/out/aignx/codegen/models/output_artifact.py index c0c34c9a7..425fa23f5 100644 --- a/codegen/out/aignx/codegen/models/output_artifact.py +++ b/codegen/out/aignx/codegen/models/output_artifact.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/output_artifact_result_read_response.py b/codegen/out/aignx/codegen/models/output_artifact_result_read_response.py index c111db09d..c4cc4db62 100644 --- a/codegen/out/aignx/codegen/models/output_artifact_result_read_response.py +++ b/codegen/out/aignx/codegen/models/output_artifact_result_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/output_artifact_scope.py b/codegen/out/aignx/codegen/models/output_artifact_scope.py index 067337c08..6e594e13b 100644 --- a/codegen/out/aignx/codegen/models/output_artifact_scope.py +++ b/codegen/out/aignx/codegen/models/output_artifact_scope.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/output_artifact_visibility.py b/codegen/out/aignx/codegen/models/output_artifact_visibility.py index 2da9ccd9d..10494e80a 100644 --- a/codegen/out/aignx/codegen/models/output_artifact_visibility.py +++ b/codegen/out/aignx/codegen/models/output_artifact_visibility.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/resource_type.py b/codegen/out/aignx/codegen/models/resource_type.py index 77025b299..c2a28f534 100644 --- a/codegen/out/aignx/codegen/models/resource_type.py +++ b/codegen/out/aignx/codegen/models/resource_type.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_creation_request.py b/codegen/out/aignx/codegen/models/run_creation_request.py index 8cfb9be7c..1ed983c99 100644 --- a/codegen/out/aignx/codegen/models/run_creation_request.py +++ b/codegen/out/aignx/codegen/models/run_creation_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_creation_response.py b/codegen/out/aignx/codegen/models/run_creation_response.py index 7c3e5c0a6..5113d271e 100644 --- a/codegen/out/aignx/codegen/models/run_creation_response.py +++ b/codegen/out/aignx/codegen/models/run_creation_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_item_statistics.py b/codegen/out/aignx/codegen/models/run_item_statistics.py index 77128c9ff..54cb9e817 100644 --- a/codegen/out/aignx/codegen/models/run_item_statistics.py +++ b/codegen/out/aignx/codegen/models/run_item_statistics.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_output.py b/codegen/out/aignx/codegen/models/run_output.py index 9d925f986..ef07a4799 100644 --- a/codegen/out/aignx/codegen/models/run_output.py +++ b/codegen/out/aignx/codegen/models/run_output.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_read_response.py b/codegen/out/aignx/codegen/models/run_read_response.py index 46caec8fa..512c380a8 100644 --- a/codegen/out/aignx/codegen/models/run_read_response.py +++ b/codegen/out/aignx/codegen/models/run_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -45,11 +45,12 @@ class RunReadResponse(BaseModel): custom_metadata_checksum: Optional[StrictStr] = None submitted_at: datetime = Field(description="Timestamp showing when the run was triggered") submitted_by: StrictStr = Field(description="Id of the user who triggered the run") + organization_id: Optional[StrictStr] = None terminated_at: Optional[datetime] = None num_preceding_items_org: Optional[StrictInt] = None num_preceding_items_platform: Optional[StrictInt] = None scheduling: Optional[SchedulingResponse] = None - __properties: ClassVar[List[str]] = ["run_id", "application_id", "version_number", "state", "output", "termination_reason", "error_code", "error_message", "statistics", "custom_metadata", "custom_metadata_checksum", "submitted_at", "submitted_by", "terminated_at", "num_preceding_items_org", "num_preceding_items_platform", "scheduling"] + __properties: ClassVar[List[str]] = ["run_id", "application_id", "version_number", "state", "output", "termination_reason", "error_code", "error_message", "statistics", "custom_metadata", "custom_metadata_checksum", "submitted_at", "submitted_by", "organization_id", "terminated_at", "num_preceding_items_org", "num_preceding_items_platform", "scheduling"] model_config = ConfigDict( populate_by_name=True, @@ -121,6 +122,11 @@ def to_dict(self) -> Dict[str, Any]: if self.custom_metadata_checksum is None and "custom_metadata_checksum" in self.model_fields_set: _dict['custom_metadata_checksum'] = None + # set to None if organization_id (nullable) is None + # and model_fields_set contains the field + if self.organization_id is None and "organization_id" in self.model_fields_set: + _dict['organization_id'] = None + # set to None if terminated_at (nullable) is None # and model_fields_set contains the field if self.terminated_at is None and "terminated_at" in self.model_fields_set: @@ -166,6 +172,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "custom_metadata_checksum": obj.get("custom_metadata_checksum"), "submitted_at": obj.get("submitted_at"), "submitted_by": obj.get("submitted_by"), + "organization_id": obj.get("organization_id"), "terminated_at": obj.get("terminated_at"), "num_preceding_items_org": obj.get("num_preceding_items_org"), "num_preceding_items_platform": obj.get("num_preceding_items_platform"), diff --git a/codegen/out/aignx/codegen/models/run_state.py b/codegen/out/aignx/codegen/models/run_state.py index 23ed86681..c3f8a8820 100644 --- a/codegen/out/aignx/codegen/models/run_state.py +++ b/codegen/out/aignx/codegen/models/run_state.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/run_termination_reason.py b/codegen/out/aignx/codegen/models/run_termination_reason.py index a22517f6c..287129db4 100644 --- a/codegen/out/aignx/codegen/models/run_termination_reason.py +++ b/codegen/out/aignx/codegen/models/run_termination_reason.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/scheduling_request.py b/codegen/out/aignx/codegen/models/scheduling_request.py index cb5cb0007..3f8c48bdd 100644 --- a/codegen/out/aignx/codegen/models/scheduling_request.py +++ b/codegen/out/aignx/codegen/models/scheduling_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/scheduling_response.py b/codegen/out/aignx/codegen/models/scheduling_response.py index 9f305e343..341e13434 100644 --- a/codegen/out/aignx/codegen/models/scheduling_response.py +++ b/codegen/out/aignx/codegen/models/scheduling_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/share_token_create_request.py b/codegen/out/aignx/codegen/models/share_token_create_request.py index a639fcba0..3cd16149b 100644 --- a/codegen/out/aignx/codegen/models/share_token_create_request.py +++ b/codegen/out/aignx/codegen/models/share_token_create_request.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/share_token_create_response.py b/codegen/out/aignx/codegen/models/share_token_create_response.py index b0964d986..9a4f00563 100644 --- a/codegen/out/aignx/codegen/models/share_token_create_response.py +++ b/codegen/out/aignx/codegen/models/share_token_create_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/share_token_read_response.py b/codegen/out/aignx/codegen/models/share_token_read_response.py index 722bfa1bd..d84cb9546 100644 --- a/codegen/out/aignx/codegen/models/share_token_read_response.py +++ b/codegen/out/aignx/codegen/models/share_token_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/subject_type.py b/codegen/out/aignx/codegen/models/subject_type.py index e0a5c8441..b29133983 100644 --- a/codegen/out/aignx/codegen/models/subject_type.py +++ b/codegen/out/aignx/codegen/models/subject_type.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/user_read_response.py b/codegen/out/aignx/codegen/models/user_read_response.py index b1d8f1806..81d0c3a69 100644 --- a/codegen/out/aignx/codegen/models/user_read_response.py +++ b/codegen/out/aignx/codegen/models/user_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/validation_error.py b/codegen/out/aignx/codegen/models/validation_error.py index 7eef142d2..f1c84a7e5 100644 --- a/codegen/out/aignx/codegen/models/validation_error.py +++ b/codegen/out/aignx/codegen/models/validation_error.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/validation_error_loc_inner.py b/codegen/out/aignx/codegen/models/validation_error_loc_inner.py index 022f4a69a..d2dd2072f 100644 --- a/codegen/out/aignx/codegen/models/validation_error_loc_inner.py +++ b/codegen/out/aignx/codegen/models/validation_error_loc_inner.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/version_document_response.py b/codegen/out/aignx/codegen/models/version_document_response.py index f63a43711..7007e99c5 100644 --- a/codegen/out/aignx/codegen/models/version_document_response.py +++ b/codegen/out/aignx/codegen/models/version_document_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/version_document_visibility.py b/codegen/out/aignx/codegen/models/version_document_visibility.py index aeb3840c5..5fab1f449 100644 --- a/codegen/out/aignx/codegen/models/version_document_visibility.py +++ b/codegen/out/aignx/codegen/models/version_document_visibility.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/models/version_read_response.py b/codegen/out/aignx/codegen/models/version_read_response.py index 2ac813115..0d3a434de 100644 --- a/codegen/out/aignx/codegen/models/version_read_response.py +++ b/codegen/out/aignx/codegen/models/version_read_response.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/aignx/codegen/rest.py b/codegen/out/aignx/codegen/rest.py index 1f8f90e77..e55aa4613 100644 --- a/codegen/out/aignx/codegen/rest.py +++ b/codegen/out/aignx/codegen/rest.py @@ -5,7 +5,7 @@ The Aignostics Platform is a cloud-based service that enables organizations to access advanced computational pathology applications through a secure API. The platform provides standardized access to Aignostics' portfolio of computational pathology solutions, with Atlas H&E-TME serving as an example of the available API endpoints. To begin using the platform, your organization must first be registered by our business support team. If you don't have an account yet, please contact your account manager or email support@aignostics.com to get started. More information about our applications can be found on [https://platform.aignostics.com](https://platform.aignostics.com). **How to authorize and test API endpoints:** 1. Click the \"Authorize\" button in the right corner below 3. Click \"Authorize\" button in the dialog to log in with your Aignostics Platform credentials 4. After successful login, you'll be redirected back and can use \"Try it out\" on any endpoint **Note**: You only need to authorize once per session. The lock icons next to endpoints will show green when authorized. - The version of the OpenAPI document: 1.6.0 + The version of the OpenAPI document: 1.6.0+dev.e1f10d7ad5b Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/codegen/out/docs/PublicApi.md b/codegen/out/docs/PublicApi.md index 6ae6863b3..a686c2d6a 100644 --- a/codegen/out/docs/PublicApi.md +++ b/codegen/out/docs/PublicApi.md @@ -1293,7 +1293,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **list_grants_v1_access_grants_get** -> List[GrantReadResponse] list_grants_v1_access_grants_get(resource_type=resource_type, resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, revoked=revoked, page=page, page_size=page_size, sort=sort) +> List[GrantReadResponse] list_grants_v1_access_grants_get(resource_type=resource_type, resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, relation=relation, revoked=revoked, page=page, page_size=page_size, sort=sort) List Grants @@ -1306,6 +1306,7 @@ List grants. Org admins see all grants for all resources in their organization. ```python import aignx.codegen from aignx.codegen.models.grant_read_response import GrantReadResponse +from aignx.codegen.models.grant_relation import GrantRelation from aignx.codegen.models.resource_type import ResourceType from aignx.codegen.models.subject_type import SubjectType from aignx.codegen.rest import ApiException @@ -1332,6 +1333,7 @@ with aignx.codegen.ApiClient(configuration) as api_client: resource_id = 'resource_id_example' # str | (optional) subject_type = aignx.codegen.SubjectType() # SubjectType | (optional) subject_id = 'subject_id_example' # str | (optional) + relation = [aignx.codegen.GrantRelation()] # List[GrantRelation] | Filter grants by relation type. Can be specified multiple times. (optional) revoked = True # bool | (optional) page = 1 # int | (optional) (default to 1) page_size = 50 # int | (optional) (default to 50) @@ -1339,7 +1341,7 @@ with aignx.codegen.ApiClient(configuration) as api_client: try: # List Grants - api_response = api_instance.list_grants_v1_access_grants_get(resource_type=resource_type, resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, revoked=revoked, page=page, page_size=page_size, sort=sort) + api_response = api_instance.list_grants_v1_access_grants_get(resource_type=resource_type, resource_id=resource_id, subject_type=subject_type, subject_id=subject_id, relation=relation, revoked=revoked, page=page, page_size=page_size, sort=sort) print("The response of PublicApi->list_grants_v1_access_grants_get:\n") pprint(api_response) except Exception as e: @@ -1357,6 +1359,7 @@ Name | Type | Description | Notes **resource_id** | **str**| | [optional] **subject_type** | [**SubjectType**](.md)| | [optional] **subject_id** | **str**| | [optional] + **relation** | [**List[GrantRelation]**](GrantRelation.md)| Filter grants by relation type. Can be specified multiple times. | [optional] **revoked** | **bool**| | [optional] **page** | **int**| | [optional] [default to 1] **page_size** | **int**| | [optional] [default to 50] @@ -1482,7 +1485,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **list_runs_v1_runs_get** -> List[RunReadResponse] list_runs_v1_runs_get(application_id=application_id, application_version=application_version, external_id=external_id, custom_metadata=custom_metadata, page=page, page_size=page_size, for_organization=for_organization, sort=sort) +> List[RunReadResponse] list_runs_v1_runs_get(application_id=application_id, application_version=application_version, external_id=external_id, custom_metadata=custom_metadata, page=page, page_size=page_size, submitted_by=submitted_by, organization_id=organization_id, for_organization=for_organization, sort=sort) List Runs @@ -1521,12 +1524,14 @@ with aignx.codegen.ApiClient(configuration) as api_client: custom_metadata = '$' # str | Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata. #### URL Encoding Required **Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding. #### Examples (Clear Format): - **Field existence**: `$.study` - Runs that have a study field defined - **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value - **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75 - **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\" - **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements #### Examples (URL-Encoded Format): - **Field existence**: `%24.study` - **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)` - **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)` - **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)` - **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)` #### Notes - JSONPath expressions are evaluated using PostgreSQL's `@?` operator - The `$.` prefix is automatically added to root-level field references if missing - String values in conditions must be enclosed in double quotes - Use `&&` for AND operations and `||` for OR operations - Regular expressions use `like_regex` with standard regex syntax - **Please remember to URL-encode the entire JSONPath expression when making HTTP requests** (optional) page = 1 # int | (optional) (default to 1) page_size = 50 # int | (optional) (default to 50) + submitted_by = 'submitted_by_example' # str | Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user. (optional) + organization_id = 'organization_id_example' # str | Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization. (optional) for_organization = 'for_organization_example' # str | Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs. (optional) sort = ['sort_example'] # List[str] | Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) (optional) try: # List Runs - api_response = api_instance.list_runs_v1_runs_get(application_id=application_id, application_version=application_version, external_id=external_id, custom_metadata=custom_metadata, page=page, page_size=page_size, for_organization=for_organization, sort=sort) + api_response = api_instance.list_runs_v1_runs_get(application_id=application_id, application_version=application_version, external_id=external_id, custom_metadata=custom_metadata, page=page, page_size=page_size, submitted_by=submitted_by, organization_id=organization_id, for_organization=for_organization, sort=sort) print("The response of PublicApi->list_runs_v1_runs_get:\n") pprint(api_response) except Exception as e: @@ -1546,6 +1551,8 @@ Name | Type | Description | Notes **custom_metadata** | **str**| Use PostgreSQL JSONPath expressions to filter runs by their custom_metadata. #### URL Encoding Required **Important**: JSONPath expressions contain special characters that must be URL-encoded when used in query parameters. Most HTTP clients handle this automatically, but when constructing URLs manually, please ensure proper encoding. #### Examples (Clear Format): - **Field existence**: `$.study` - Runs that have a study field defined - **Exact value match**: `$.study ? (@ == \"high\")` - Runs with specific study value - **Numeric comparison**: `$.confidence_score ? (@ > 0.75)` - Runs with confidence score greater than 0.75 - **Array operations**: `$.tags[*] ? (@ == \"draft\")` - Runs with tags array containing \"draft\" - **Complex conditions**: `$.resources ? (@.gpu_count > 2 && @.memory_gb >= 16)` - Runs with high resource requirements #### Examples (URL-Encoded Format): - **Field existence**: `%24.study` - **Exact value match**: `%24.study%20%3F%20(%40%20%3D%3D%20%22high%22)` - **Numeric comparison**: `%24.confidence_score%20%3F%20(%40%20%3E%200.75)` - **Array operations**: `%24.tags%5B*%5D%20%3F%20(%40%20%3D%3D%20%22draft%22)` - **Complex conditions**: `%24.resources%20%3F%20(%40.gpu_count%20%3E%202%20%26%26%20%40.memory_gb%20%3E%3D%2016)` #### Notes - JSONPath expressions are evaluated using PostgreSQL's `@?` operator - The `$.` prefix is automatically added to root-level field references if missing - String values in conditions must be enclosed in double quotes - Use `&&` for AND operations and `||` for OR operations - Regular expressions use `like_regex` with standard regex syntax - **Please remember to URL-encode the entire JSONPath expression when making HTTP requests** | [optional] **page** | **int**| | [optional] [default to 1] **page_size** | **int**| | [optional] [default to 50] + **submitted_by** | **str**| Filter runs by the user who submitted them. Use the special value `me` to return only runs submitted by the current user. | [optional] + **organization_id** | **str**| Filter runs by the organization of the submitter. Use the special value `my_org` to filter by the current user's organization. | [optional] **for_organization** | **str**| Filter runs by organization ID. Available for superadmins (any org) and admins (own org only). When provided, returns all runs for the specified organization instead of only the caller's own runs. | [optional] **sort** | [**List[str]**](str.md)| Sort the results by one or more fields. Use `+` for ascending and `-` for descending order. **Available fields:** - `run_id` - `application_id` - `version_number` - `custom_metadata` - `submitted_at` - `submitted_by` - `terminated_at` - `termination_reason` **Examples:** - `?sort=submitted_at` - Sort by creation time (ascending) - `?sort=-submitted_at` - Sort by creation time (descending) - `?sort=state&sort=-submitted_at` - Sort by state, then by time (descending) | [optional] diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 9ee527859..3be0e4bac 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -130,6 +130,15 @@ result_app = typer.Typer() run_app.add_typer(result_app, name="result", help="Download or delete run results.") +share_app = typer.Typer() +run_app.add_typer(share_app, name="share", help="Manage run sharing and access.") + +share_organization_app = typer.Typer() +share_app.add_typer(share_organization_app, name="organization", help="Manage organization access grants.") + +share_token_app = typer.Typer() +share_app.add_typer(share_token_app, name="token", help="Manage share tokens for link-based access.") + version_app = typer.Typer() cli.add_typer(version_app, name="version", help="Inspect application versions and their release documents.") @@ -1303,6 +1312,185 @@ def run_update_item_metadata( sys.exit(1) +@share_app.command("status") +def run_share_status( + run_id: Annotated[str, typer.Argument(..., help="Id of the run")], + format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 +) -> None: + """Show sharing status: active organization grants and share tokens.""" + try: + grants = list(Service().application_run_organization_grants(run_id)) + tokens = list(Service().application_run_share_tokens(run_id)) + if format == "json": + print( + json.dumps( + { + "organization_grants": [g.model_dump() for g in grants], + "share_tokens": [t.model_dump() for t in tokens], + }, + indent=2, + default=str, + ) + ) + else: + console.print(f"[bold]Organization grants[/bold] ({len(grants)}):") + for g in grants: + console.print(f" {g.grant_id} subject={g.subject_id} relation={g.relation.value}") + console.print(f"[bold]Share tokens[/bold] ({len(tokens)}):") + for t in tokens: + expires = t.expires_at.isoformat() if t.expires_at else "never" + created = t.created_at.isoformat() if t.created_at else "unknown" + console.print(f" {t.share_token_id} created={created} expires={expires}") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to retrieve share status for run '{}'", run_id) + console.print(f"[error]Error:[/error] Failed to retrieve share status for run '{run_id}': {e}") + sys.exit(1) + + +@share_organization_app.command("list") +def run_share_organization_list( + run_id: Annotated[str, typer.Argument(..., help="Id of the run")], + format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 +) -> None: + """List active organization grants for a run.""" + try: + grants = list(Service().application_run_organization_grants(run_id)) + if format == "json": + print(json.dumps([g.model_dump() for g in grants], indent=2, default=str)) + else: + if not grants: + console.print("No active organization grants.") + for g in grants: + console.print( + f"{g.grant_id} subject={g.subject_id}" + f" relation={g.relation.value} created={g.created_at.isoformat()}" + ) + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to list organization grants for run '{}'", run_id) + console.print(f"[error]Error:[/error] Failed to list organization grants for run '{run_id}': {e}") + sys.exit(1) + + +@share_organization_app.command("grant") +def run_share_organization_grant( + run_id: Annotated[str, typer.Argument(..., help="Id of the run to share")], + organization_id: Annotated[ + str | None, typer.Option(help="Organization ID to share with (defaults to your own organization)") + ] = None, + format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 +) -> None: + """Share a run with all users in your organization.""" + try: + grant = Service().application_run_share_with_organization(run_id, organization_id=organization_id) + if format == "json": + print(json.dumps(grant.model_dump(), indent=2, default=str)) + else: + console.print(f"Run '{run_id}' is now shared with organization (grant {grant.grant_id}).") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to share run '{}' with organization", run_id) + console.print(f"[error]Error:[/error] Failed to share run '{run_id}' with organization: {e}") + sys.exit(1) + + +@share_organization_app.command("revoke") +def run_share_organization_revoke( + run_id: Annotated[str, typer.Argument(..., help="Id of the run to unshare")], + organization_id: Annotated[ + str | None, typer.Option(help="Organization ID to revoke access for (defaults to your own organization)") + ] = None, +) -> None: + """Revoke all organization grants for a run.""" + try: + Service().application_run_unshare_with_organization(run_id, organization_id=organization_id) + console.print(f"Organization access revoked for run '{run_id}'.") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to revoke organization access for run '{}'", run_id) + console.print(f"[error]Error:[/error] Failed to revoke organization access for run '{run_id}': {e}") + sys.exit(1) + + +@share_token_app.command("list") +def run_share_token_list( + run_id: Annotated[str, typer.Argument(..., help="Id of the run")], + format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 +) -> None: + """List active share tokens for a run.""" + try: + tokens = list(Service().application_run_share_tokens(run_id)) + if format == "json": + print(json.dumps([t.model_dump() for t in tokens], indent=2, default=str)) + else: + if not tokens: + console.print("No active share tokens.") + for t in tokens: + expires = t.expires_at.isoformat() if t.expires_at else "never" + created = t.created_at.isoformat() if t.created_at else "unknown" + console.print(f"{t.share_token_id} created={created} expires={expires}") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to list share tokens for run '{}'", run_id) + console.print(f"[error]Error:[/error] Failed to list share tokens for run '{run_id}': {e}") + sys.exit(1) + + +@share_token_app.command("create") +def run_share_token_create( + run_id: Annotated[str, typer.Argument(..., help="Id of the run to create a share token for")], + format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 +) -> None: + """Create a share token for a run. The token value is shown only once.""" + try: + token = Service().application_run_create_share_token(run_id) + if format == "json": + print(json.dumps(token.model_dump(), indent=2, default=str)) + else: + expires = token.expires_at.isoformat() if token.expires_at else "never" + console.print(f"Share token created for run '{run_id}'.") + console.print(f" Token ID : {token.share_token_id}") + console.print(f" Token : [bold]{token.token}[/bold]") + console.print(f" Expires : {expires}") + console.print("[yellow]Save the token value — it will not be shown again.[/yellow]") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to create share token for run '{}'", run_id) + console.print(f"[error]Error:[/error] Failed to create share token for run '{run_id}': {e}") + sys.exit(1) + + +@share_token_app.command("revoke") +def run_share_token_revoke( + run_id: Annotated[str, typer.Argument(..., help="Id of the run")], + token_id: Annotated[str, typer.Argument(..., help="Id of the share token to revoke")], +) -> None: + """Revoke a share token.""" + try: + Service().application_run_revoke_share_token(run_id, token_id) + console.print(f"Share token '{token_id}' revoked for run '{run_id}'.") + except NotFoundException: + console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + sys.exit(2) + except Exception as e: + logger.exception("Failed to revoke share token '{}' for run '{}'", token_id, run_id) + console.print(f"[error]Error:[/error] Failed to revoke share token '{token_id}' for run '{run_id}': {e}") + sys.exit(1) + + @result_app.command("download") def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917 run_id: Annotated[str, typer.Argument(..., help="Id of the run to download results for")], diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 315c674f6..3ddd3a3d9 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -3,7 +3,7 @@ import base64 import re import time -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Iterator from datetime import datetime from http import HTTPStatus from importlib.util import find_spec @@ -27,10 +27,12 @@ InputArtifact, InputItem, NotFoundException, + OrganizationGrant, Run, RunData, RunOutput, RunState, + ShareToken, ) from aignostics.platform import Service as PlatformService from aignostics.utils import BaseService, Health, sanitize_path_component @@ -1323,6 +1325,161 @@ def application_run_delete(self, run_id: str) -> None: logger.exception(message) raise RuntimeError(message) from e + def application_run_organization_grants( + self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE + ) -> Iterator[OrganizationGrant]: + """List active organization grants for a run. + + Args: + run_id (str): The ID of the run. + page_size (int): Number of grants per page. Defaults to max (100). + + Returns: + Iterator[OrganizationGrant]: Active organization grants. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + return self.application_run(run_id).organization_grants(page_size=page_size) + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to list organization grants for run '{run_id}': {e}" + logger.exception(message) + raise RuntimeError(message) from e + + def application_run_share_tokens( + self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE + ) -> Iterator[ShareToken]: + """List active share tokens for a run. + + Args: + run_id (str): The ID of the run. + page_size (int): Number of tokens per page. Defaults to max (100). + + Returns: + Iterator[ShareToken]: Active share tokens. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + return self.application_run(run_id).share_tokens(page_size=page_size) + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to list share tokens for run '{run_id}': {e}" + logger.exception(message) + raise RuntimeError(message) from e + + def application_run_share_with_organization( + self, run_id: str, organization_id: str | None = None + ) -> OrganizationGrant: + """Share a run with all users in an organization. + + Args: + run_id (str): The ID of the run. + organization_id (str | None): The organization ID to share with. + If None, the caller's organization ID is resolved via the /me endpoint. + + Returns: + OrganizationGrant: The created grant. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + return self.application_run(run_id).share_with_organization(organization_id=organization_id) + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to share run '{run_id}' with organization: {e}" + logger.exception(message) + raise RuntimeError(message) from e + + def application_run_unshare_with_organization(self, run_id: str, organization_id: str | None = None) -> None: + """Revoke all active organization grants for a run. + + Args: + run_id (str): The ID of the run. + organization_id (str | None): Only revoke grants for this organization ID. + If None, the caller's organization ID is resolved via the /me endpoint. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + run = self.application_run(run_id) + org_id = organization_id or self._get_platform_client().me().organization.id + for grant in run.organization_grants(nocache=True): + if grant.subject_id == org_id: + grant.revoke() + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to unshare run '{run_id}' with organization: {e}" + logger.exception(message) + raise RuntimeError(message) from e + + def application_run_create_share_token(self, run_id: str) -> ShareToken: + """Create a share token for a run. + + Args: + run_id (str): The ID of the run. + + Returns: + ShareToken: The created token, including the one-time ``token`` value. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + return self.application_run(run_id).create_share_token() + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to create share token for run '{run_id}': {e}" + logger.exception(message) + raise RuntimeError(message) from e + + def application_run_revoke_share_token(self, run_id: str, share_token_id: str) -> None: + """Revoke a share token for a run. + + Args: + run_id (str): The ID of the run. + share_token_id (str): The ID of the share token to revoke. + + Raises: + NotFoundException: If the run is not found. + RuntimeError: If the request fails unexpectedly. + """ + try: + self.application_run(run_id).share_token(share_token_id).revoke() + except NotFoundException as e: + message = f"Application run with ID '{run_id}' not found: {e}" + logger.warning(message) + raise NotFoundException(message) from e + except Exception as e: + message = f"Failed to revoke share token '{share_token_id}' for run '{run_id}': {e}" + logger.exception(message) + raise RuntimeError(message) from e + @staticmethod def application_run_download_static( # noqa: PLR0913, PLR0917 run_id: str, diff --git a/src/aignostics/platform/_client.py b/src/aignostics/platform/_client.py index 5968c0e79..a73cd2b1a 100644 --- a/src/aignostics/platform/_client.py +++ b/src/aignostics/platform/_client.py @@ -30,6 +30,7 @@ from aignostics.utils import user_agent from ._settings import settings +from .resources.access import ShareToken, ShareTokens # Safety bound for the external token-provider cache. In normal usage callers # reuse a single provider reference, so this limit should never be reached. @@ -54,6 +55,7 @@ class Client: applications: Applications versions: Versions runs: Runs + share_tokens: ShareTokens def __init__(self, cache_token: bool = True, token_provider: Callable[[], str] | None = None) -> None: """Initializes a client instance with authenticated API access. @@ -78,6 +80,7 @@ def __init__(self, cache_token: bool = True, token_provider: Callable[[], str] | self._api = Client.get_api_client(cache_token=cache_token, token_provider=token_provider) self.applications: Applications = Applications(self._api) self.runs: Runs = Runs(self._api) + self.share_tokens: ShareTokens = ShareTokens(self._api) self.versions: Versions = Versions(self._api) logger.trace("Client initialized successfully.") except Exception: diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py new file mode 100644 index 000000000..931323cd6 --- /dev/null +++ b/src/aignostics/platform/resources/access.py @@ -0,0 +1,197 @@ +"""Access-control resources: organization grants and share tokens.""" +import builtins +from collections.abc import Iterator +from datetime import datetime +from typing import Protocol, cast + +from aignx.codegen.models import ( + GrantReadResponse, + GrantRelation, + ShareTokenCreateRequest, + SubjectType, +) +from pydantic import BaseModel, ConfigDict +from tenacity import Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter + +from aignostics.platform._api import RETRYABLE_EXCEPTIONS, _AuthenticatedApi, _AuthenticatedResource, _log_retry_attempt +from aignostics.platform._operation_cache import cached_operation, operation_cache_clear +from aignostics.platform._settings import settings +from aignostics.platform.resources.utils import paginate +from aignostics.utils import user_agent + + +class ShareSubject(Protocol): + """An active share subject (duck-type interface for grant targets).""" + + subject_type: SubjectType + subject_id: str + + +class AccessGrant(BaseModel): + """An active share grant. + + Obtained from ``Run.share_grants()`` + Call ``revoke()`` to remove access. + """ + model_config = ConfigDict(arbitrary_types_allowed=True) + + api: _AuthenticatedApi + grant_id: str + subject_id: str + subject_type: SubjectType + relation: GrantRelation + created_at: datetime + revoked: bool + + def revoke(self) -> None: + """Revoke this grant. + + Raises: + Exception: If the API request fails. + """ + self.api.revoke_grant_v1_access_grants_grant_id_delete( + grant_id=self.grant_id, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + operation_cache_clear() + + @classmethod + def for_grant_id(cls, grant_id: str, cache_token: bool = True) -> "AccessGrant": + from aignostics.platform._client import Client # noqa: PLC0415 + + return Client.get_api_client( + cache_token=cache_token).get_grant_v1_access_grants_grant_id_get( + grant_id=grant_id, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + + +class ShareToken(BaseModel): + """A share token granting access to a run. + + When returned from ``Run.create_share_token()``, the one-time ``token`` + value is populated. For tokens obtained from ``Run.share_tokens()``, + ``token`` is ``None`` because the secret is never stored after creation. + Call ``revoke()`` to invalidate the token. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + api: _AuthenticatedApi + share_token_id: str + revoked: bool + created_at: datetime + expires_at: datetime | None = None + share_token: str | None = None + + @classmethod + def for_token_id(cls, share_token_id: str, cache_token: bool = True) -> "ShareToken": + from aignostics.platform._client import Client # noqa: PLC0415 + + return Client.get_api_client(cache_token=cache_token).get_share_token_v1_access_share_tokens_share_token_id_get( + share_token_id=share_token_id, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + + def grants(self, *, page_size: int = 100) -> Iterator[AccessGrant]: + """List the run grants associated with this share token. + + Each returned grant represents a run this token can access. + Call ``grant.revoke()`` to remove access to a specific run. + + Args: + page_size: Number of grants to fetch per page (max 100). + + Returns: + Iterator[RunGrant]: Grants giving this token access to runs. + + Raises: + Exception: If the API request fails. + """ + + def fetch_page(**kwargs: object) -> list[GrantReadResponse]: + return cast( + "list[GrantReadResponse]", + self.api.list_grants_v1_access_grants_get( + subject_type=SubjectType.SHARE_TOKEN, + subject_id=self.share_token_id, + revoked=False, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + **kwargs, # pyright: ignore[reportArgumentType] + ), + ) + + return ( + AccessGrant( + api=self.api, + **g.__dict__ + ) + for g in paginate(fetch_page, page_size=page_size) + ) + + def revoke(self) -> None: + """Revoke this share token. + + Raises: + Exception: If the API request fails. + """ + self.api.revoke_share_token_v1_access_share_tokens_share_token_id_delete( + share_token_id=self.share_token_id, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + operation_cache_clear() + + +class ShareTokens(_AuthenticatedResource): + + def __init__(self, api: _AuthenticatedApi) -> None: + super().__init__(api) + + def list(self, *, nocache=False, page_size: int = 100) -> Iterator[ShareToken]: + + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) + def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: + return Retrying( + retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), + stop=stop_after_attempt(settings().run_retry_attempts), + wait=wait_exponential_jitter(initial=settings().run_retry_wait_min, max=settings().run_retry_wait_max), + before_sleep=_log_retry_attempt, + reraise=True, + )( + lambda: [ShareToken(api=self._api, **t.__dict__) for t in self._api.list_share_tokens_v1_access_share_tokens_get( + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + **kwargs, # pyright: ignore[reportArgumentType] + )] + ) + + return paginate( + lambda **kwargs: list_data_with_retry( + nocache=nocache, + **kwargs, + ), + page_size=page_size, + ) + + def create( + self, + expires_at: datetime | None = None, + ): + """Create a new share token.""" + share_token = self._api.create_share_token_v1_access_share_tokens_post( + share_token_create_request=ShareTokenCreateRequest( + expires_at=expires_at + ), + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + + return ShareToken( + api=self._api, + **share_token.__dict__ + ) diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 508a547bc..12a261e7c 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -14,19 +14,26 @@ from typing import Any, cast import requests + +from aignostics.platform.resources.access import AccessGrant, ShareSubject from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException from aignx.codegen.models import ( ArtifactOutput, CustomMetadataUpdateRequest, + GrantCreateRequest, + GrantReadResponse, + GrantRelation, ItemCreationRequest, ItemOutput, ItemResultReadResponse, ItemState, ItemTerminationReason, + ResourceType, RunCreationRequest, RunCreationResponse, RunState, SchedulingRequest, + SubjectType, ) from aignx.codegen.models import ( ItemResultReadResponse as ItemResultData, @@ -654,6 +661,83 @@ def update_item_custom_metadata( ) operation_cache_clear() # Clear all caches since we updated a run + def list_share_grants( + self, subject_type: SubjectType | None = None, subject_id: str | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False + ) -> Iterator[AccessGrant]: + """List active organization grants for this run. + + Args: + page_size (int): Number of grants per page. Defaults to max (100). + nocache (bool): If True, bypass cache and fetch fresh data. Defaults to False. + + Returns: + Iterator[OrganizationGrant]: Active grants for organization_user and organization_admin subjects. + + Raises: + ValueError: If page_size is greater than 100. + Exception: If the API request fails. + """ + if page_size > LIST_APPLICATION_RUNS_MAX_PAGE_SIZE: + message = f"page_size must be <= {LIST_APPLICATION_RUNS_MAX_PAGE_SIZE}, but got {page_size}" + raise ValueError(message) + + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) + def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: + return Retrying( + retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), + stop=stop_after_attempt(settings().run_retry_attempts), + wait=wait_exponential_jitter(initial=settings().run_retry_wait_min, max=settings().run_retry_wait_max), + before_sleep=_log_retry_attempt, + reraise=True, + )( + lambda: self._api.list_grants_v1_access_grants_get( + resource_type=ResourceType.RUN, + resource_id=self.run_id, + subject_type=subject_type, + subject_id=subject_id, + revoked=False, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + **kwargs, # pyright: ignore[reportArgumentType] + ) + ) + + return ( + AccessGrant( + api=self._api, + **g.__dict__, + ) + for g in paginate(lambda **kw: fetch_grant_page(nocache=nocache, **kw), page_size=page_size) + ) + + def grant_access(self, share_subject: ShareSubject) -> AccessGrant: + """Share this run with all users in an organization. + + Args: + + Returns: + OrganizationGrant: The created grant. + + Raises: + Exception: If the API request fails. + """ + grant = self._api.create_grant_v1_access_grants_post( + grant_create_request=GrantCreateRequest( + resource_type=ResourceType.RUN, + resource_id=self.run_id, + subject_type=share_subject.subject_type, + subject_id=share_subject.subject_id, + relation=GrantRelation.VIEWER, + ), + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) + operation_cache_clear() + return AccessGrant( + api=self._api, + **grant.__dict__ + ) + def __str__(self) -> str: """Returns a string representation of the application run. diff --git a/tests/aignostics/platform/resources/access_test.py b/tests/aignostics/platform/resources/access_test.py new file mode 100644 index 000000000..6a0986fa5 --- /dev/null +++ b/tests/aignostics/platform/resources/access_test.py @@ -0,0 +1,396 @@ +"""Unit tests for access control resources: AccessGrant, ShareToken, ShareTokens.""" + +from datetime import UTC, datetime +from unittest.mock import Mock, patch + +import pytest +from aignx.codegen.models import ( + GrantReadResponse, + GrantRelation, + ResourceType, + ShareTokenCreateRequest, + ShareTokenCreateResponse, + ShareTokenReadResponse, + SubjectType, +) + +from aignostics.platform._api import _AuthenticatedApi +from aignostics.platform.resources.access import ( + AccessGrant, + ShareToken, + ShareTokens, +) + +_GRANT_ID = "grant-001" +_TOKEN_ID = "token-001" # noqa: S105 +_TOKEN_SECRET = "secret-token-value" # noqa: S105 +_ORG_ID = "org-001" +_SUBJECT_ID = "subject-001" +_RUN_ID = "run-001" +_CREATED_AT = datetime(2024, 1, 1, tzinfo=UTC) + + +@pytest.fixture +def mock_api() -> Mock: + """Return a mock _AuthenticatedApi.""" + api = Mock(spec=_AuthenticatedApi) + api.token_provider = lambda: "test-token" + return api + + +@pytest.fixture +def share_tokens_resource(mock_api: Mock) -> ShareTokens: + """Return a ShareTokens resource bound to the mock API.""" + return ShareTokens(mock_api) + + +def _make_grant_read_response( + grant_id: str = _GRANT_ID, + subject_type: SubjectType = SubjectType.SHARE_TOKEN, + subject_id: str = _TOKEN_ID, + revoked: bool = False, +) -> GrantReadResponse: + return GrantReadResponse( + grant_id=grant_id, + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, + subject_type=subject_type, + subject_id=subject_id, + relation=GrantRelation.VIEWER, + created_by="user-1", + created_at=_CREATED_AT, + revoked=revoked, + ) + + +def _make_share_token_read_response(token_id: str = _TOKEN_ID) -> ShareTokenReadResponse: + return ShareTokenReadResponse( + share_token_id=token_id, + created_at=_CREATED_AT, + expires_at=None, + revoked=False, + ) + + +def _make_share_token_create_response( + token_id: str = _TOKEN_ID, + expires_at: datetime | None = None, +) -> ShareTokenCreateResponse: + return ShareTokenCreateResponse( + share_token_id=token_id, + share_token=_TOKEN_SECRET, + created_at=_CREATED_AT, + expires_at=expires_at, + revoked=False, + ) + + +class TestAccessGrantRevoke: + """Tests for AccessGrant.revoke().""" + + @pytest.mark.unit + @staticmethod + def test_revoke_calls_api_with_grant_id(mock_api: Mock) -> None: + """revoke() calls the revoke endpoint with the correct grant_id.""" + grant = AccessGrant( + api=mock_api, + grant_id=_GRANT_ID, + subject_id=_SUBJECT_ID, + subject_type=SubjectType.ORGANIZATION_USER, + relation=GrantRelation.VIEWER, + created_at=_CREATED_AT, + revoked=False, + ) + + with patch("aignostics.platform.resources.access.operation_cache_clear"): + grant.revoke() + + call_kw = mock_api.revoke_grant_v1_access_grants_grant_id_delete.call_args.kwargs + mock_api.revoke_grant_v1_access_grants_grant_id_delete.assert_called_once_with( + grant_id=_GRANT_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_revoke_clears_operation_cache(mock_api: Mock) -> None: + """revoke() clears the operation cache after the API call.""" + grant = AccessGrant( + api=mock_api, + grant_id=_GRANT_ID, + subject_id=_SUBJECT_ID, + subject_type=SubjectType.ORGANIZATION_USER, + relation=GrantRelation.VIEWER, + created_at=_CREATED_AT, + revoked=False, + ) + + with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: + grant.revoke() + + mock_clear.assert_called_once() + + +class TestShareTokenForTokenId: + """Tests for ShareToken.for_token_id() classmethod.""" + + @pytest.mark.unit + @staticmethod + def test_calls_api_with_token_id(mock_api: Mock) -> None: + """for_token_id() calls the get_share_token endpoint with the given ID.""" + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + + with patch("aignostics.platform._client.Client") as mock_client_cls: + mock_client_cls.get_api_client.return_value = mock_api + ShareToken.for_token_id(_TOKEN_ID) + + call_kw = mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.call_args.kwargs + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.assert_called_once_with( + share_token_id=_TOKEN_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_uses_cached_api_client_by_default(mock_api: Mock) -> None: + """for_token_id() calls get_api_client with cache_token=True by default.""" + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + + with patch("aignostics.platform._client.Client") as mock_client_cls: + mock_client_cls.get_api_client.return_value = mock_api + ShareToken.for_token_id(_TOKEN_ID) + + mock_client_cls.get_api_client.assert_called_once_with(cache_token=True) + + @pytest.mark.unit + @staticmethod + def test_cache_token_false_forwarded(mock_api: Mock) -> None: + """for_token_id(cache_token=False) passes cache_token=False to get_api_client.""" + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + + with patch("aignostics.platform._client.Client") as mock_client_cls: + mock_client_cls.get_api_client.return_value = mock_api + ShareToken.for_token_id(_TOKEN_ID, cache_token=False) + + mock_client_cls.get_api_client.assert_called_once_with(cache_token=False) + + @pytest.mark.unit + @staticmethod + def test_returns_api_response(mock_api: Mock) -> None: + """for_token_id() returns the raw value from the API call.""" + sentinel = object() + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = sentinel + + with patch("aignostics.platform._client.Client") as mock_client_cls: + mock_client_cls.get_api_client.return_value = mock_api + result = ShareToken.for_token_id(_TOKEN_ID) + + assert result is sentinel + + +class TestShareTokenGrants: + """Tests for ShareToken.grants().""" + + @pytest.mark.unit + @staticmethod + def test_returns_access_grants(mock_api: Mock) -> None: + """grants() returns AccessGrant objects for each grant from the API.""" + grant_response = _make_grant_read_response() + mock_api.list_grants_v1_access_grants_get.return_value = [grant_response] + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + result = list(token.grants()) + + assert len(result) == 1 + assert isinstance(result[0], AccessGrant) + assert result[0].grant_id == _GRANT_ID + assert result[0].relation == GrantRelation.VIEWER + + @pytest.mark.unit + @staticmethod + def test_calls_api_with_token_subject_params(mock_api: Mock) -> None: + """grants() calls list_grants with subject_type=SHARE_TOKEN and the token's id.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + list(token.grants()) + + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + assert call_kw["subject_type"] == SubjectType.SHARE_TOKEN + assert call_kw["subject_id"] == _TOKEN_ID + assert call_kw["revoked"] is False + + @pytest.mark.unit + @staticmethod + def test_returns_empty_iterator_when_no_grants(mock_api: Mock) -> None: + """grants() returns an empty iterator when the API returns no grants.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + assert list(token.grants()) == [] + + @pytest.mark.unit + @staticmethod + def test_multiple_grants_returned(mock_api: Mock) -> None: + """grants() returns all grants from the API response.""" + grant_responses = [_make_grant_read_response(grant_id=f"grant-{i}") for i in range(3)] + mock_api.list_grants_v1_access_grants_get.return_value = grant_responses + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + result = list(token.grants()) + + assert len(result) == 3 + assert all(isinstance(g, AccessGrant) for g in result) + + +class TestShareTokenRevoke: + """Tests for ShareToken.revoke().""" + + @pytest.mark.unit + @staticmethod + def test_revoke_calls_api_with_token_id(mock_api: Mock) -> None: + """revoke() calls the revoke endpoint with the correct share_token_id.""" + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + with patch("aignostics.platform.resources.access.operation_cache_clear"): + token.revoke() + + call_kw = mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.call_args.kwargs + mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.assert_called_once_with( + share_token_id=_TOKEN_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_revoke_clears_operation_cache(mock_api: Mock) -> None: + """revoke() clears the operation cache after the API call.""" + token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) + + with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: + token.revoke() + + mock_clear.assert_called_once() + + +class TestShareTokensList: + """Tests for ShareTokens.list().""" + + @pytest.mark.unit + @staticmethod + def test_returns_share_tokens(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """list() returns ShareToken objects from the API response.""" + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [_make_share_token_read_response()] + + result = list(share_tokens_resource.list()) + + assert len(result) == 1 + assert isinstance(result[0], ShareToken) + assert result[0].share_token_id == _TOKEN_ID + assert result[0].share_token is None # Secrets are absent in read responses + + @pytest.mark.unit + @staticmethod + def test_returns_empty_list_when_none(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """list() returns an empty iterator when the API returns no tokens.""" + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [] + + assert list(share_tokens_resource.list()) == [] + + @pytest.mark.unit + @staticmethod + def test_multiple_tokens_returned(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """list() returns all tokens from the API response.""" + responses = [_make_share_token_read_response(f"token-{i}") for i in range(3)] + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = responses + + result = list(share_tokens_resource.list()) + + assert len(result) == 3 + assert all(isinstance(t, ShareToken) for t in result) + assert {t.share_token_id for t in result} == {"token-0", "token-1", "token-2"} + + @pytest.mark.unit + @staticmethod + def test_nocache_bypasses_cache_and_fetches_fresh_data(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """list(nocache=True) bypasses the cache and calls the API again.""" + first = _make_share_token_read_response("token-first") + second = _make_share_token_read_response("token-second") + mock_api.list_share_tokens_v1_access_share_tokens_get.side_effect = [[first], [second]] + + result1 = list(share_tokens_resource.list()) + result2 = list(share_tokens_resource.list(nocache=True)) + + assert result1[0].share_token_id == "token-first" # noqa: S105 + assert result2[0].share_token_id == "token-second" # noqa: S105 + assert mock_api.list_share_tokens_v1_access_share_tokens_get.call_count == 2 + + @pytest.mark.unit + @staticmethod + def test_default_list_uses_cache_on_second_call(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """list() without nocache returns cached result on the second call.""" + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [_make_share_token_read_response()] + + list(share_tokens_resource.list()) + list(share_tokens_resource.list()) + + mock_api.list_share_tokens_v1_access_share_tokens_get.assert_called_once() + + +class TestShareTokensCreate: + """Tests for ShareTokens.create().""" + + @pytest.mark.unit + @staticmethod + def test_create_returns_share_token_with_secret(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """create() returns a ShareToken that includes the one-time token secret.""" + mock_api.create_share_token_v1_access_share_tokens_post.return_value = _make_share_token_create_response() + + result = share_tokens_resource.create() + + assert isinstance(result, ShareToken) + assert result.share_token_id == _TOKEN_ID + assert result.share_token == _TOKEN_SECRET + + @pytest.mark.unit + @staticmethod + def test_create_without_expires_at_passes_none(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """create() passes expires_at=None to the API when not specified.""" + mock_api.create_share_token_v1_access_share_tokens_post.return_value = _make_share_token_create_response() + + share_tokens_resource.create() + + call_kw = mock_api.create_share_token_v1_access_share_tokens_post.call_args.kwargs + req: ShareTokenCreateRequest = call_kw["share_token_create_request"] + assert req.expires_at is None + + @pytest.mark.unit + @staticmethod + def test_create_with_expires_at_forwards_value(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """create(expires_at=...) forwards the expiry to the API and returns it on the token.""" + expires = datetime(2025, 12, 31, tzinfo=UTC) + mock_api.create_share_token_v1_access_share_tokens_post.return_value = _make_share_token_create_response( + expires_at=expires + ) + + result = share_tokens_resource.create(expires_at=expires) + + call_kw = mock_api.create_share_token_v1_access_share_tokens_post.call_args.kwargs + req: ShareTokenCreateRequest = call_kw["share_token_create_request"] + assert req.expires_at == expires + assert result.expires_at == expires + + @pytest.mark.unit + @staticmethod + def test_create_returns_token_with_correct_metadata(share_tokens_resource: ShareTokens, mock_api: Mock) -> None: + """create() maps all fields from the API response onto the returned ShareToken.""" + mock_api.create_share_token_v1_access_share_tokens_post.return_value = _make_share_token_create_response() + + result = share_tokens_resource.create() + + assert result.created_at == _CREATED_AT + assert result.revoked is False + assert result.expires_at is None diff --git a/tests/aignostics/platform/resources/run_sharing_test.py b/tests/aignostics/platform/resources/run_sharing_test.py new file mode 100644 index 000000000..e4c15dd64 --- /dev/null +++ b/tests/aignostics/platform/resources/run_sharing_test.py @@ -0,0 +1,420 @@ +"""Unit tests for Run sharing methods.""" + +from datetime import UTC, datetime +from unittest.mock import Mock, patch + +import pytest +from aignx.codegen.models import ( + GrantCreateRequest, + GrantReadResponse, + GrantRelation, + MeReadResponse, + ResourceType, + ShareTokenCreateRequest, + ShareTokenCreateResponse, + ShareTokenReadResponse, + SubjectType, +) + +from aignostics.platform._api import _AuthenticatedApi +from aignostics.platform.resources.access import OrganizationGrant, RunGrant, ShareToken +from aignostics.platform.resources.runs import Run + +_RUN_ID = "550e8400-e29b-41d4-a716-446655440000" +_ORG_ID = "org-001" +_GRANT_ID = "grant-001" +_TOKEN_ID = "token-001" # noqa: S105 +_TOKEN_VALUE = "secret-share-token" # noqa: S105 + + +@pytest.fixture +def mock_api() -> Mock: + """Return a mock _AuthenticatedApi.""" + api = Mock(spec=_AuthenticatedApi) + api.token_provider = lambda: "test-token" + return api + + +@pytest.fixture +def run(mock_api: Mock) -> Run: + """Return a Run bound to the mock API.""" + return Run(mock_api, _RUN_ID) + + +def _make_grant( + grant_id: str = _GRANT_ID, + subject_type: SubjectType = SubjectType.ORGANIZATION_USER, + subject_id: str = _ORG_ID, + revoked: bool = False, +) -> GrantReadResponse: + """Build a minimal GrantReadResponse for testing.""" + return GrantReadResponse( + grant_id=grant_id, + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, + subject_type=subject_type, + subject_id=subject_id, + relation=GrantRelation.VIEWER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + revoked=revoked, + ) + + +def _make_share_token_response(token_id: str = _TOKEN_ID) -> ShareTokenReadResponse: + """Build a minimal ShareTokenReadResponse for testing.""" + return ShareTokenReadResponse( + share_token_id=token_id, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + expires_at=None, + revoked=False, + ) + + +def _make_me(org_id: str = _ORG_ID) -> MeReadResponse: + """Build a minimal MeReadResponse mock for testing.""" + me = Mock(spec=MeReadResponse) + me.organization = Mock() + me.organization.id = org_id + return me + + +class TestOrganizationGrants: + """Tests for Run.organization_grants().""" + + @pytest.mark.unit + @staticmethod + def test_returns_org_grants(run: Run, mock_api: Mock) -> None: + """organization_grants() returns OrganizationGrant objects for org_user/org_admin.""" + org_grant = _make_grant(subject_type=SubjectType.ORGANIZATION_USER) + token_grant = _make_grant(grant_id="g2", subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID) + mock_api.list_grants_v1_access_grants_get.return_value = [org_grant, token_grant] + + result = list(run.organization_grants()) + + assert len(result) == 1 + assert isinstance(result[0], OrganizationGrant) + assert result[0].grant_id == _GRANT_ID + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + mock_api.list_grants_v1_access_grants_get.assert_called_once_with( + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, + revoked=False, + page=1, + page_size=100, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_includes_org_admin_grants(run: Run, mock_api: Mock) -> None: + """organization_grants() includes organization_admin subject type.""" + admin_grant = _make_grant(subject_type=SubjectType.ORGANIZATION_ADMIN) + mock_api.list_grants_v1_access_grants_get.return_value = [admin_grant] + + result = list(run.organization_grants()) + + assert len(result) == 1 + assert isinstance(result[0], OrganizationGrant) + + @pytest.mark.unit + @staticmethod + def test_returns_empty_list_when_none(run: Run, mock_api: Mock) -> None: + """organization_grants() returns an empty iterator when the API returns no grants.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] + + result = list(run.organization_grants()) + + assert result == [] + + +class TestShareTokens: + """Tests for Run.share_tokens().""" + + @pytest.mark.unit + @staticmethod + def test_returns_share_tokens(run: Run, mock_api: Mock) -> None: + """share_tokens() returns ShareToken objects from the API.""" + token = _make_share_token_response() + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [token] + + result = list(run.share_tokens()) + + assert len(result) == 1 + assert isinstance(result[0], ShareToken) + assert result[0].share_token_id == _TOKEN_ID + assert result[0].token is None + call_kw = mock_api.list_share_tokens_v1_access_share_tokens_get.call_args.kwargs + mock_api.list_share_tokens_v1_access_share_tokens_get.assert_called_once_with( + run_id=_RUN_ID, + revoked=False, + page=1, + page_size=100, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_returns_empty_list_when_none(run: Run, mock_api: Mock) -> None: + """share_tokens() returns an empty iterator when the API returns no tokens.""" + mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [] + + result = list(run.share_tokens()) + + assert result == [] + + +class TestShareWithOrganization: + """Tests for Run.share_with_organization().""" + + @pytest.mark.unit + @staticmethod + def test_creates_org_user_grant(run: Run, mock_api: Mock) -> None: + """share_with_organization() resolves org ID via me() and returns OrganizationGrant.""" + mock_api.get_me_v1_me_get.return_value = _make_me() + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant() + + with patch("aignostics.platform.resources.runs.operation_cache_clear") as mock_clear: + result = run.share_with_organization() + + assert isinstance(result, OrganizationGrant) + assert result.grant_id == _GRANT_ID + assert result.subject_id == _ORG_ID + assert result.relation == GrantRelation.VIEWER + mock_api.get_me_v1_me_get.assert_called_once() + mock_api.create_grant_v1_access_grants_post.assert_called_once() + req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] + assert req.resource_type == ResourceType.RUN + assert req.resource_id == _RUN_ID + assert req.subject_type == SubjectType.ORGANIZATION_USER + assert req.subject_id == _ORG_ID + assert req.relation == GrantRelation.VIEWER + mock_clear.assert_called_once() + + @pytest.mark.unit + @staticmethod + def test_uses_org_id_from_me(run: Run, mock_api: Mock) -> None: + """share_with_organization() uses the org ID from me() as subject_id.""" + mock_api.get_me_v1_me_get.return_value = _make_me(org_id="other-org") + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant(subject_id="other-org") + + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + run.share_with_organization() + + req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] + assert req.subject_id == "other-org" + + @pytest.mark.unit + @staticmethod + def test_uses_explicit_org_id_without_calling_me(run: Run, mock_api: Mock) -> None: + """share_with_organization(organization_id=...) skips the /me call.""" + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant(subject_id="explicit-org") + + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + result = run.share_with_organization(organization_id="explicit-org") + + mock_api.get_me_v1_me_get.assert_not_called() + assert isinstance(result, OrganizationGrant) + req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] + assert req.subject_id == "explicit-org" + + +class TestOrganizationGrantRevoke: + """Tests for OrganizationGrant.revoke().""" + + @pytest.mark.unit + @staticmethod + def test_revoke_calls_api_and_clears_cache(mock_api: Mock) -> None: + """revoke() calls the revoke endpoint and clears the operation cache.""" + data = _make_grant() + grant = OrganizationGrant( + mock_api, + str(data.grant_id), + subject_id=str(data.subject_id), + subject_type=data.subject_type, + relation=data.relation, + created_at=data.created_at, + revoked=bool(data.revoked), + ) + + with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: + grant.revoke() + + call_kw = mock_api.revoke_grant_v1_access_grants_grant_id_delete.call_args.kwargs + mock_api.revoke_grant_v1_access_grants_grant_id_delete.assert_called_once_with( + grant_id=_GRANT_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + mock_clear.assert_called_once() + + +class TestCreateShareToken: + """Tests for Run.create_share_token().""" + + @pytest.mark.unit + @staticmethod + def test_creates_token_and_grant(run: Run, mock_api: Mock) -> None: + """create_share_token() creates the token, binds it via a grant, and returns ShareToken.""" + token_response = ShareTokenCreateResponse( + share_token_id=_TOKEN_ID, + share_token=_TOKEN_VALUE, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + expires_at=None, + revoked=False, + ) + mock_api.create_share_token_v1_access_share_tokens_post.return_value = token_response + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant( + subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID + ) + + with patch("aignostics.platform.resources.runs.operation_cache_clear") as mock_clear: + result = run.create_share_token() + + assert isinstance(result, ShareToken) + assert result.share_token_id == _TOKEN_ID + assert result.token == _TOKEN_VALUE + assert result.created_at == datetime(2024, 1, 1, tzinfo=UTC) + assert result.expires_at is None + mock_api.create_share_token_v1_access_share_tokens_post.assert_called_once() + grant_req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs[ + "grant_create_request" + ] + assert grant_req.resource_type == ResourceType.RUN + assert grant_req.resource_id == _RUN_ID + assert grant_req.subject_type == SubjectType.SHARE_TOKEN + assert grant_req.subject_id == _TOKEN_ID + assert grant_req.relation == GrantRelation.VIEWER + mock_clear.assert_called_once() + + @pytest.mark.unit + @staticmethod + def test_passes_expires_at(run: Run, mock_api: Mock) -> None: + """create_share_token() forwards expires_at to the share-token creation request.""" + expires = datetime(2025, 12, 31, tzinfo=UTC) + token_response = ShareTokenCreateResponse( + share_token_id=_TOKEN_ID, + share_token=_TOKEN_VALUE, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + expires_at=expires, + revoked=False, + ) + mock_api.create_share_token_v1_access_share_tokens_post.return_value = token_response + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant( + subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID + ) + + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + result = run.create_share_token(expires_at=expires) + + token_req: ShareTokenCreateRequest = mock_api.create_share_token_v1_access_share_tokens_post.call_args.kwargs[ + "share_token_create_request" + ] + assert token_req.expires_at == expires + assert result.expires_at == expires + + +class TestShareTokenGrants: + """Tests for ShareToken.grants().""" + + @pytest.mark.unit + @staticmethod + def test_returns_run_grants(mock_api: Mock) -> None: + """grants() returns RunGrant objects for each grant associated with the token.""" + grant = GrantReadResponse( + grant_id=_GRANT_ID, + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, + subject_type=SubjectType.SHARE_TOKEN, + subject_id=_TOKEN_ID, + relation=GrantRelation.VIEWER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + revoked=False, + ) + mock_api.list_grants_v1_access_grants_get.return_value = [grant] + token = ShareToken(mock_api, _TOKEN_ID) + + result = list(token.grants()) + + assert len(result) == 1 + assert isinstance(result[0], RunGrant) + assert result[0].grant_id == _GRANT_ID + assert result[0].run_id == _RUN_ID + assert result[0].relation == GrantRelation.VIEWER + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + mock_api.list_grants_v1_access_grants_get.assert_called_once_with( + subject_type=SubjectType.SHARE_TOKEN, + subject_id=_TOKEN_ID, + revoked=False, + page=1, + page_size=100, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + + @pytest.mark.unit + @staticmethod + def test_returns_empty_list_when_none(mock_api: Mock) -> None: + """grants() returns an empty iterator when the API returns no grants.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] + token = ShareToken(mock_api, _TOKEN_ID) + + assert list(token.grants()) == [] + + @pytest.mark.unit + @staticmethod + def test_run_grant_revoke(mock_api: Mock) -> None: + """RunGrant.revoke() calls the revoke endpoint and clears the cache.""" + grant = RunGrant( + mock_api, + _GRANT_ID, + run_id=_RUN_ID, + relation=GrantRelation.VIEWER, + created_at=datetime(2024, 1, 1, tzinfo=UTC), + revoked=False, + ) + + with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: + grant.revoke() + + call_kw = mock_api.revoke_grant_v1_access_grants_grant_id_delete.call_args.kwargs + mock_api.revoke_grant_v1_access_grants_grant_id_delete.assert_called_once_with( + grant_id=_GRANT_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + mock_clear.assert_called_once() + + +class TestShareTokenRevoke: + """Tests for ShareToken.revoke() and Run.share_token() factory.""" + + @pytest.mark.unit + @staticmethod + def test_revoke_calls_api_and_clears_cache(mock_api: Mock) -> None: + """ShareToken.revoke() calls the revoke endpoint and clears the cache.""" + token = ShareToken(mock_api, _TOKEN_ID) + + with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: + token.revoke() + + call_kw = mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.call_args.kwargs + mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.assert_called_once_with( + share_token_id=_TOKEN_ID, + _request_timeout=call_kw["_request_timeout"], + _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + ) + mock_clear.assert_called_once() + + @pytest.mark.unit + @staticmethod + def test_run_share_token_factory(run: Run, mock_api: Mock) -> None: + """Run.share_token(id) returns a ShareToken handle without making an API call.""" + token = run.share_token(_TOKEN_ID) + + assert isinstance(token, ShareToken) + assert token.share_token_id == _TOKEN_ID + mock_api.assert_not_called() From 57bd3842a803b35b3f8c51878f3af418336ab6fd Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 16:32:22 +0200 Subject: [PATCH 02/15] Update docs --- src/aignostics/platform/_client.py | 2 +- src/aignostics/platform/resources/access.py | 333 +++++++++++++-- src/aignostics/platform/resources/runs.py | 9 +- .../platform/resources/access_test.py | 55 --- .../platform/resources/run_sharing_test.py | 404 +++++------------- 5 files changed, 400 insertions(+), 403 deletions(-) diff --git a/src/aignostics/platform/_client.py b/src/aignostics/platform/_client.py index a73cd2b1a..bb054731b 100644 --- a/src/aignostics/platform/_client.py +++ b/src/aignostics/platform/_client.py @@ -30,7 +30,7 @@ from aignostics.utils import user_agent from ._settings import settings -from .resources.access import ShareToken, ShareTokens +from .resources.access import ShareTokens # Safety bound for the external token-provider cache. In normal usage callers # reuse a single provider reference, so this limit should never be reached. diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index 931323cd6..ca92cdc18 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -1,8 +1,57 @@ -"""Access-control resources: organization grants and share tokens.""" +"""Access-control resources: organization grants and share tokens. + +This module provides classes for managing access to Aignostics platform resources. +There are two complementary mechanisms: + +* **Share grants** (``AccessGrant``) — delegate access to an existing platform + user or organization directly. Grants are always associated with a specific + resource (e.g. a run) and a subject (e.g. an organization). + +* **Share tokens** (``ShareToken``) — create a short-lived, revocable secret that + can be handed to anyone. The recipient exchanges the token for a grant without + needing a platform account. + +Typical workflow:: + + from aignostics.platform import Client + from aignx.codegen.models import SubjectType + + client = Client() + + # --- Share a run with another organization via a grant --- + run = client.run("run-abc123") + grant = run.grant_access( + subject_type=SubjectType.ORGANIZATION_USER, + subject_id="org-xyz", + ) + print(f"Granted access: {grant.grant_id}") + + # List all active grants on the run + for g in run.list_share_grants(): + print(g.grant_id, g.subject_type, g.subject_id) + + # Revoke a specific grant + grant.revoke() + + # --- Share a run via a one-time token --- + token = client.share_tokens.create() + print(f"Share this token secret once: {token.share_token}") + + # Grant the token access to the run + run.grant_access( + subject_type=SubjectType.SHARE_TOKEN, + subject_id=token.share_token_id, + ) + + # List tokens and revoke one + for t in client.share_tokens.list(): + print(t.share_token_id, t.expires_at) + token.revoke() +""" import builtins from collections.abc import Iterator from datetime import datetime -from typing import Protocol, cast +from typing import Any, cast from aignx.codegen.models import ( GrantReadResponse, @@ -10,7 +59,7 @@ ShareTokenCreateRequest, SubjectType, ) -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, PrivateAttr from tenacity import Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter from aignostics.platform._api import RETRYABLE_EXCEPTIONS, _AuthenticatedApi, _AuthenticatedResource, _log_retry_attempt @@ -20,22 +69,37 @@ from aignostics.utils import user_agent -class ShareSubject(Protocol): - """An active share subject (duck-type interface for grant targets).""" +class AccessGrant(BaseModel): + """An active access grant linking a platform resource to a subject. - subject_type: SubjectType - subject_id: str + A grant gives a *subject* (an organization, an organization user, or a + share token) a specific *relation* (e.g. ``VIEWER``) on a *resource*. + Instances are returned by resource-level helpers such as + ``resource.grant_access()`` and ``resource.list_share_grants()``, or + fetched directly via ``AccessGrant.for_grant_id()``. -class AccessGrant(BaseModel): - """An active share grant. + Attributes: + grant_id: Unique identifier for this grant. + subject_id: Identifier of the entity that was granted access. + subject_type: Category of the subject (``ORGANIZATION_ADMIN``, + ``ORGANIZATION_USER``, or ``SHARE_TOKEN``). + relation: Level of access granted (currently always ``VIEWER``). + created_at: UTC timestamp when the grant was created. + revoked: ``True`` if the grant has already been revoked. + + Example:: + + grant = AccessGrant.for_grant_id("grant-abc123") + print(grant.subject_type, grant.relation, grant.revoked) - Obtained from ``Run.share_grants()`` - Call ``revoke()`` to remove access. + # Remove the grant + grant.revoke() """ + model_config = ConfigDict(arbitrary_types_allowed=True) - api: _AuthenticatedApi + _api: _AuthenticatedApi = PrivateAttr() grant_id: str subject_id: str subject_type: SubjectType @@ -43,51 +107,135 @@ class AccessGrant(BaseModel): created_at: datetime revoked: bool + def __init__(self, *, api: _AuthenticatedApi, **data: Any) -> None: # noqa: ANN401, D107 + super().__init__(**data) + self._api = api + def revoke(self) -> None: - """Revoke this grant. + """Revoke this grant, removing the subject's access to the resource. + + After this call the in-memory ``revoked`` attribute is *not* updated; + call ``AccessGrant.for_grant_id(self.grant_id)`` if you need a fresh + server-side view. Raises: Exception: If the API request fails. """ - self.api.revoke_grant_v1_access_grants_grant_id_delete( + self._api.revoke_grant_v1_access_grants_grant_id_delete( grant_id=self.grant_id, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) operation_cache_clear() - @classmethod - def for_grant_id(cls, grant_id: str, cache_token: bool = True) -> "AccessGrant": - from aignostics.platform._client import Client # noqa: PLC0415 + @classmethod + def for_grant_id(cls, grant_id: str, cache_token: bool = True) -> "AccessGrant": + """Retrieve a single grant by its ID. - return Client.get_api_client( - cache_token=cache_token).get_grant_v1_access_grants_grant_id_get( - grant_id=grant_id, - _request_timeout=settings().run_timeout, - _headers={"User-Agent": user_agent()}, - ) + Args: + grant_id: The unique identifier of the grant to fetch. + cache_token: Whether to use the cached authentication token. + Defaults to ``True``. + + Returns: + The ``AccessGrant`` corresponding to *grant_id*. + + Raises: + NotFoundException: If no grant with the given ID exists. + Exception: If the API request fails. + + Example:: + + grant = AccessGrant.for_grant_id("grant-abc123") + print(grant.subject_type, grant.revoked) + """ + from aignostics.platform._client import Client # noqa: PLC0415 + + return Client.get_api_client( + cache_token=cache_token).get_grant_v1_access_grants_grant_id_get( + grant_id=grant_id, + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) class ShareToken(BaseModel): - """A share token granting access to a run. + """A share token that can be used to grant access to platform resources. + + Share tokens decouple *token creation* from *grant creation*: a token is + minted first, then attached to one or more resources as a subject of type + ``SHARE_TOKEN``. The secret value (``share_token``) is only available + immediately after creation — it is never stored by the platform and will be + ``None`` for tokens fetched later via ``ShareToken.for_token_id()``. + + Attributes: + share_token_id: Stable identifier for this token (safe to persist). + revoked: ``True`` if the token has been revoked. + created_at: UTC timestamp when the token was created. + expires_at: Optional UTC expiry; ``None`` means the token never expires. + share_token: One-time secret value. Only present immediately after + ``ShareTokens.create()``; ``None`` for subsequently fetched tokens. + + Example:: + + from aignostics.platform import Client + + client = Client() - When returned from ``Run.create_share_token()``, the one-time ``token`` - value is populated. For tokens obtained from ``Run.share_tokens()``, - ``token`` is ``None`` because the secret is never stored after creation. - Call ``revoke()`` to invalidate the token. + # Create a token and note the secret — it won't be retrievable later + token = client.share_tokens.create() + secret = token.share_token # store or transmit this once + token_id = token.share_token_id # stable ID for revocation + + # Fetch the token record later (secret is gone) + fetched = ShareToken.for_token_id(token_id) + assert fetched.share_token is None + + # List grants created for this token + for grant in fetched.list_share_grants(): + print(grant.grant_id, grant.relation) + + # Revoke the token (all associated grants become ineffective) + fetched.revoke() """ model_config = ConfigDict(arbitrary_types_allowed=True) - api: _AuthenticatedApi + _api: _AuthenticatedApi = PrivateAttr() share_token_id: str revoked: bool created_at: datetime expires_at: datetime | None = None share_token: str | None = None + def __init__(self, *, api: _AuthenticatedApi, **data: Any) -> None: # noqa: ANN401, D107 + super().__init__(**data) + self._api = api + @classmethod def for_token_id(cls, share_token_id: str, cache_token: bool = True) -> "ShareToken": + """Retrieve a share token record by its stable ID. + + The returned object will have ``share_token = None`` because the secret + is only returned at creation time. + + Args: + share_token_id: The stable ID of the token to fetch. + cache_token: Whether to use the cached authentication token. + Defaults to ``True``. + + Returns: + The ``ShareToken`` corresponding to *share_token_id*. + + Raises: + NotFoundException: If no token with the given ID exists. + Exception: If the API request fails. + + Example:: + + token = ShareToken.for_token_id("tok-abc123") + print(token.revoked, token.expires_at) + """ from aignostics.platform._client import Client # noqa: PLC0415 return Client.get_api_client(cache_token=cache_token).get_share_token_v1_access_share_tokens_share_token_id_get( @@ -96,26 +244,33 @@ def for_token_id(cls, share_token_id: str, cache_token: bool = True) -> "ShareTo _headers={"User-Agent": user_agent()}, ) - def grants(self, *, page_size: int = 100) -> Iterator[AccessGrant]: - """List the run grants associated with this share token. + def list_share_grants(self, *, page_size: int = 100) -> Iterator[AccessGrant]: + """List all active grants where this token is the subject. - Each returned grant represents a run this token can access. - Call ``grant.revoke()`` to remove access to a specific run. + Each returned ``AccessGrant`` represents a resource this token has been + granted access to. Call ``grant.revoke()`` to remove access to a + specific resource without invalidating the token itself. Args: page_size: Number of grants to fetch per page (max 100). Returns: - Iterator[RunGrant]: Grants giving this token access to runs. + Iterator of ``AccessGrant`` objects for this token. Raises: Exception: If the API request fails. + + Example:: + + token = client.share_tokens.create() + for grant in token.list_share_grants(): + print(grant.grant_id, grant.relation) """ def fetch_page(**kwargs: object) -> list[GrantReadResponse]: return cast( "list[GrantReadResponse]", - self.api.list_grants_v1_access_grants_get( + self._api.list_grants_v1_access_grants_get( subject_type=SubjectType.SHARE_TOKEN, subject_id=self.share_token_id, revoked=False, @@ -127,19 +282,24 @@ def fetch_page(**kwargs: object) -> list[GrantReadResponse]: return ( AccessGrant( - api=self.api, + api=self._api, **g.__dict__ ) for g in paginate(fetch_page, page_size=page_size) ) def revoke(self) -> None: - """Revoke this share token. + """Revoke this share token, invalidating all grants associated with it. + + After revocation any resource that was shared via this token becomes + inaccessible to its holder. The in-memory ``revoked`` attribute is + *not* updated in-place; fetch a fresh record via + ``ShareToken.for_token_id()`` if you need the server-side state. Raises: Exception: If the API request fails. """ - self.api.revoke_share_token_v1_access_share_tokens_share_token_id_delete( + self._api.revoke_share_token_v1_access_share_tokens_share_token_id_delete( share_token_id=self.share_token_id, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, @@ -148,11 +308,61 @@ def revoke(self) -> None: class ShareTokens(_AuthenticatedResource): + """Collection resource for managing share tokens. - def __init__(self, api: _AuthenticatedApi) -> None: + Accessible as ``client.share_tokens``. Use ``create()`` to mint a new + token and ``list()`` to enumerate existing ones. + + Example:: + + from aignostics.platform import Client + from datetime import datetime, timedelta, timezone + + client = Client() + + # Create a token that expires in 7 days + token = client.share_tokens.create( + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + print("Secret (store once):", token.share_token) + print("Token ID:", token.share_token_id) + + # List all active tokens + for t in client.share_tokens.list(): + print(t.share_token_id, t.expires_at, t.revoked) + """ + + def __init__(self, api: _AuthenticatedApi) -> None: # noqa: D107 super().__init__(api) - def list(self, *, nocache=False, page_size: int = 100) -> Iterator[ShareToken]: + def list(self, *, nocache: bool = False, page_size: int = 100) -> Iterator[ShareToken]: + """List all share tokens for the authenticated user. + + Results are cached for ``run_cache_ttl`` seconds and retried on + transient network or server errors. + + Args: + nocache: If ``True``, bypass the local cache and fetch fresh data + from the API. The fetched result is still written to the cache. + Defaults to ``False``. + page_size: Number of tokens to fetch per page (max 100). + Defaults to 100. + + Returns: + Iterator of ``ShareToken`` objects. + + Raises: + Exception: If the API request fails after all retries. + + Example:: + + for token in client.share_tokens.list(): + print(token.share_token_id, token.revoked) + + # Force a fresh fetch after creating a new token + for token in client.share_tokens.list(nocache=True): + print(token.share_token_id) + """ @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: @@ -163,11 +373,14 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: before_sleep=_log_retry_attempt, reraise=True, )( - lambda: [ShareToken(api=self._api, **t.__dict__) for t in self._api.list_share_tokens_v1_access_share_tokens_get( - _request_timeout=settings().run_timeout, - _headers={"User-Agent": user_agent()}, - **kwargs, # pyright: ignore[reportArgumentType] - )] + lambda: [ + ShareToken(api=self._api, **t.__dict__) + for t in self._api.list_share_tokens_v1_access_share_tokens_get( + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + **kwargs, # pyright: ignore[reportArgumentType] + ) + ] ) return paginate( @@ -181,8 +394,34 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: def create( self, expires_at: datetime | None = None, - ): - """Create a new share token.""" + ) -> ShareToken: + """Create a new share token. + + The returned ``ShareToken`` contains the one-time secret in + ``share_token``. This is the **only** time the secret is returned by + the API — subsequent fetches via ``ShareToken.for_token_id()`` will + have ``share_token = None``. + + Args: + expires_at: Optional UTC datetime at which the token expires. + Pass ``None`` (default) for a token that never expires. + + Returns: + A newly created ``ShareToken`` with ``share_token`` populated. + + Raises: + Exception: If the API request fails. + + Example:: + + from datetime import datetime, timedelta, timezone + + # Token valid for 24 hours + token = client.share_tokens.create( + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + secret = token.share_token # transmit to the intended recipient + """ share_token = self._api.create_share_token_v1_access_share_tokens_post( share_token_create_request=ShareTokenCreateRequest( expires_at=expires_at diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 12a261e7c..3660cd41e 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -14,8 +14,6 @@ from typing import Any, cast import requests - -from aignostics.platform.resources.access import AccessGrant, ShareSubject from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException from aignx.codegen.models import ( ArtifactOutput, @@ -78,6 +76,7 @@ get_mime_type_for_artifact, mime_type_to_file_ending, ) +from aignostics.platform.resources.access import AccessGrant from aignostics.platform.resources.applications import Versions from aignostics.platform.resources.utils import paginate from aignostics.utils import user_agent @@ -710,7 +709,7 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: for g in paginate(lambda **kw: fetch_grant_page(nocache=nocache, **kw), page_size=page_size) ) - def grant_access(self, share_subject: ShareSubject) -> AccessGrant: + def grant_access(self, subject_type: SubjectType, subject_id: str) -> AccessGrant: """Share this run with all users in an organization. Args: @@ -725,8 +724,8 @@ def grant_access(self, share_subject: ShareSubject) -> AccessGrant: grant_create_request=GrantCreateRequest( resource_type=ResourceType.RUN, resource_id=self.run_id, - subject_type=share_subject.subject_type, - subject_id=share_subject.subject_id, + subject_type=subject_type, + subject_id=subject_id, relation=GrantRelation.VIEWER, ), _request_timeout=settings().run_timeout, diff --git a/tests/aignostics/platform/resources/access_test.py b/tests/aignostics/platform/resources/access_test.py index 6a0986fa5..d90c43720 100644 --- a/tests/aignostics/platform/resources/access_test.py +++ b/tests/aignostics/platform/resources/access_test.py @@ -190,61 +190,6 @@ def test_returns_api_response(mock_api: Mock) -> None: assert result is sentinel -class TestShareTokenGrants: - """Tests for ShareToken.grants().""" - - @pytest.mark.unit - @staticmethod - def test_returns_access_grants(mock_api: Mock) -> None: - """grants() returns AccessGrant objects for each grant from the API.""" - grant_response = _make_grant_read_response() - mock_api.list_grants_v1_access_grants_get.return_value = [grant_response] - token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) - - result = list(token.grants()) - - assert len(result) == 1 - assert isinstance(result[0], AccessGrant) - assert result[0].grant_id == _GRANT_ID - assert result[0].relation == GrantRelation.VIEWER - - @pytest.mark.unit - @staticmethod - def test_calls_api_with_token_subject_params(mock_api: Mock) -> None: - """grants() calls list_grants with subject_type=SHARE_TOKEN and the token's id.""" - mock_api.list_grants_v1_access_grants_get.return_value = [] - token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) - - list(token.grants()) - - call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs - assert call_kw["subject_type"] == SubjectType.SHARE_TOKEN - assert call_kw["subject_id"] == _TOKEN_ID - assert call_kw["revoked"] is False - - @pytest.mark.unit - @staticmethod - def test_returns_empty_iterator_when_no_grants(mock_api: Mock) -> None: - """grants() returns an empty iterator when the API returns no grants.""" - mock_api.list_grants_v1_access_grants_get.return_value = [] - token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) - - assert list(token.grants()) == [] - - @pytest.mark.unit - @staticmethod - def test_multiple_grants_returned(mock_api: Mock) -> None: - """grants() returns all grants from the API response.""" - grant_responses = [_make_grant_read_response(grant_id=f"grant-{i}") for i in range(3)] - mock_api.list_grants_v1_access_grants_get.return_value = grant_responses - token = ShareToken(api=mock_api, share_token_id=_TOKEN_ID, revoked=False, created_at=_CREATED_AT) - - result = list(token.grants()) - - assert len(result) == 3 - assert all(isinstance(g, AccessGrant) for g in result) - - class TestShareTokenRevoke: """Tests for ShareToken.revoke().""" diff --git a/tests/aignostics/platform/resources/run_sharing_test.py b/tests/aignostics/platform/resources/run_sharing_test.py index e4c15dd64..02c286514 100644 --- a/tests/aignostics/platform/resources/run_sharing_test.py +++ b/tests/aignostics/platform/resources/run_sharing_test.py @@ -1,4 +1,4 @@ -"""Unit tests for Run sharing methods.""" +"""Unit tests for Run sharing methods: list_share_grants and grant_access.""" from datetime import UTC, datetime from unittest.mock import Mock, patch @@ -8,23 +8,18 @@ GrantCreateRequest, GrantReadResponse, GrantRelation, - MeReadResponse, ResourceType, - ShareTokenCreateRequest, - ShareTokenCreateResponse, - ShareTokenReadResponse, SubjectType, ) from aignostics.platform._api import _AuthenticatedApi -from aignostics.platform.resources.access import OrganizationGrant, RunGrant, ShareToken +from aignostics.platform.resources.access import AccessGrant from aignostics.platform.resources.runs import Run _RUN_ID = "550e8400-e29b-41d4-a716-446655440000" _ORG_ID = "org-001" _GRANT_ID = "grant-001" -_TOKEN_ID = "token-001" # noqa: S105 -_TOKEN_VALUE = "secret-share-token" # noqa: S105 +_CREATED_AT = datetime(2024, 1, 1, tzinfo=UTC) @pytest.fixture @@ -41,13 +36,12 @@ def run(mock_api: Mock) -> Run: return Run(mock_api, _RUN_ID) -def _make_grant( +def _make_grant_response( grant_id: str = _GRANT_ID, subject_type: SubjectType = SubjectType.ORGANIZATION_USER, subject_id: str = _ORG_ID, revoked: bool = False, ) -> GrantReadResponse: - """Build a minimal GrantReadResponse for testing.""" return GrantReadResponse( grant_id=grant_id, resource_type=ResourceType.RUN, @@ -56,365 +50,185 @@ def _make_grant( subject_id=subject_id, relation=GrantRelation.VIEWER, created_by="user-1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), + created_at=_CREATED_AT, revoked=revoked, ) -def _make_share_token_response(token_id: str = _TOKEN_ID) -> ShareTokenReadResponse: - """Build a minimal ShareTokenReadResponse for testing.""" - return ShareTokenReadResponse( - share_token_id=token_id, - created_at=datetime(2024, 1, 1, tzinfo=UTC), - expires_at=None, - revoked=False, - ) - - -def _make_me(org_id: str = _ORG_ID) -> MeReadResponse: - """Build a minimal MeReadResponse mock for testing.""" - me = Mock(spec=MeReadResponse) - me.organization = Mock() - me.organization.id = org_id - return me - - -class TestOrganizationGrants: - """Tests for Run.organization_grants().""" +class TestRunListShareGrants: + """Tests for Run.list_share_grants().""" @pytest.mark.unit @staticmethod - def test_returns_org_grants(run: Run, mock_api: Mock) -> None: - """organization_grants() returns OrganizationGrant objects for org_user/org_admin.""" - org_grant = _make_grant(subject_type=SubjectType.ORGANIZATION_USER) - token_grant = _make_grant(grant_id="g2", subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID) - mock_api.list_grants_v1_access_grants_get.return_value = [org_grant, token_grant] + def test_returns_access_grants(run: Run, mock_api: Mock) -> None: + """list_share_grants() yields AccessGrant objects from the API response.""" + mock_api.list_grants_v1_access_grants_get.return_value = [_make_grant_response()] - result = list(run.organization_grants()) + result = list(run.list_share_grants()) assert len(result) == 1 - assert isinstance(result[0], OrganizationGrant) + assert isinstance(result[0], AccessGrant) assert result[0].grant_id == _GRANT_ID - call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs - mock_api.list_grants_v1_access_grants_get.assert_called_once_with( - resource_type=ResourceType.RUN, - resource_id=_RUN_ID, - revoked=False, - page=1, - page_size=100, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, - ) + assert result[0].relation == GrantRelation.VIEWER @pytest.mark.unit @staticmethod - def test_includes_org_admin_grants(run: Run, mock_api: Mock) -> None: - """organization_grants() includes organization_admin subject type.""" - admin_grant = _make_grant(subject_type=SubjectType.ORGANIZATION_ADMIN) - mock_api.list_grants_v1_access_grants_get.return_value = [admin_grant] + def test_calls_api_with_run_resource_params(run: Run, mock_api: Mock) -> None: + """list_share_grants() passes resource_type=RUN and the run's ID to the API.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] - result = list(run.organization_grants()) + list(run.list_share_grants()) - assert len(result) == 1 - assert isinstance(result[0], OrganizationGrant) + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + assert call_kw["resource_type"] == ResourceType.RUN + assert call_kw["resource_id"] == _RUN_ID + assert call_kw["revoked"] is False @pytest.mark.unit @staticmethod - def test_returns_empty_list_when_none(run: Run, mock_api: Mock) -> None: - """organization_grants() returns an empty iterator when the API returns no grants.""" + def test_default_filters_are_none(run: Run, mock_api: Mock) -> None: + """list_share_grants() passes subject_type=None and subject_id=None by default.""" mock_api.list_grants_v1_access_grants_get.return_value = [] - result = list(run.organization_grants()) + list(run.list_share_grants()) - assert result == [] - - -class TestShareTokens: - """Tests for Run.share_tokens().""" + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + assert call_kw["subject_type"] is None + assert call_kw["subject_id"] is None @pytest.mark.unit @staticmethod - def test_returns_share_tokens(run: Run, mock_api: Mock) -> None: - """share_tokens() returns ShareToken objects from the API.""" - token = _make_share_token_response() - mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [token] + def test_passes_subject_type_filter(run: Run, mock_api: Mock) -> None: + """list_share_grants(subject_type=...) forwards the filter to the API.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] - result = list(run.share_tokens()) + list(run.list_share_grants(subject_type=SubjectType.SHARE_TOKEN)) - assert len(result) == 1 - assert isinstance(result[0], ShareToken) - assert result[0].share_token_id == _TOKEN_ID - assert result[0].token is None - call_kw = mock_api.list_share_tokens_v1_access_share_tokens_get.call_args.kwargs - mock_api.list_share_tokens_v1_access_share_tokens_get.assert_called_once_with( - run_id=_RUN_ID, - revoked=False, - page=1, - page_size=100, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, - ) + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + assert call_kw["subject_type"] == SubjectType.SHARE_TOKEN @pytest.mark.unit @staticmethod - def test_returns_empty_list_when_none(run: Run, mock_api: Mock) -> None: - """share_tokens() returns an empty iterator when the API returns no tokens.""" - mock_api.list_share_tokens_v1_access_share_tokens_get.return_value = [] - - result = list(run.share_tokens()) - - assert result == [] + def test_passes_subject_id_filter(run: Run, mock_api: Mock) -> None: + """list_share_grants(subject_id=...) forwards the filter to the API.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] + list(run.list_share_grants(subject_id="token-abc")) -class TestShareWithOrganization: - """Tests for Run.share_with_organization().""" + call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs + assert call_kw["subject_id"] == "token-abc" @pytest.mark.unit @staticmethod - def test_creates_org_user_grant(run: Run, mock_api: Mock) -> None: - """share_with_organization() resolves org ID via me() and returns OrganizationGrant.""" - mock_api.get_me_v1_me_get.return_value = _make_me() - mock_api.create_grant_v1_access_grants_post.return_value = _make_grant() - - with patch("aignostics.platform.resources.runs.operation_cache_clear") as mock_clear: - result = run.share_with_organization() + def test_returns_empty_iterator_when_no_grants(run: Run, mock_api: Mock) -> None: + """list_share_grants() returns an empty iterator when the API returns no grants.""" + mock_api.list_grants_v1_access_grants_get.return_value = [] - assert isinstance(result, OrganizationGrant) - assert result.grant_id == _GRANT_ID - assert result.subject_id == _ORG_ID - assert result.relation == GrantRelation.VIEWER - mock_api.get_me_v1_me_get.assert_called_once() - mock_api.create_grant_v1_access_grants_post.assert_called_once() - req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] - assert req.resource_type == ResourceType.RUN - assert req.resource_id == _RUN_ID - assert req.subject_type == SubjectType.ORGANIZATION_USER - assert req.subject_id == _ORG_ID - assert req.relation == GrantRelation.VIEWER - mock_clear.assert_called_once() + assert list(run.list_share_grants()) == [] @pytest.mark.unit @staticmethod - def test_uses_org_id_from_me(run: Run, mock_api: Mock) -> None: - """share_with_organization() uses the org ID from me() as subject_id.""" - mock_api.get_me_v1_me_get.return_value = _make_me(org_id="other-org") - mock_api.create_grant_v1_access_grants_post.return_value = _make_grant(subject_id="other-org") + def test_returns_multiple_grants(run: Run, mock_api: Mock) -> None: + """list_share_grants() returns all grants from the API response.""" + responses = [_make_grant_response(grant_id=f"grant-{i}") for i in range(3)] + mock_api.list_grants_v1_access_grants_get.return_value = responses - with patch("aignostics.platform.resources.runs.operation_cache_clear"): - run.share_with_organization() + result = list(run.list_share_grants()) - req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] - assert req.subject_id == "other-org" + assert len(result) == 3 + assert all(isinstance(g, AccessGrant) for g in result) @pytest.mark.unit @staticmethod - def test_uses_explicit_org_id_without_calling_me(run: Run, mock_api: Mock) -> None: - """share_with_organization(organization_id=...) skips the /me call.""" - mock_api.create_grant_v1_access_grants_post.return_value = _make_grant(subject_id="explicit-org") - - with patch("aignostics.platform.resources.runs.operation_cache_clear"): - result = run.share_with_organization(organization_id="explicit-org") - - mock_api.get_me_v1_me_get.assert_not_called() - assert isinstance(result, OrganizationGrant) - req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] - assert req.subject_id == "explicit-org" - - -class TestOrganizationGrantRevoke: - """Tests for OrganizationGrant.revoke().""" + def test_raises_for_page_size_exceeding_max(run: Run) -> None: + """list_share_grants() raises ValueError when page_size > 100.""" + with pytest.raises(ValueError, match="page_size"): + list(run.list_share_grants(page_size=101)) @pytest.mark.unit @staticmethod - def test_revoke_calls_api_and_clears_cache(mock_api: Mock) -> None: - """revoke() calls the revoke endpoint and clears the operation cache.""" - data = _make_grant() - grant = OrganizationGrant( - mock_api, - str(data.grant_id), - subject_id=str(data.subject_id), - subject_type=data.subject_type, - relation=data.relation, - created_at=data.created_at, - revoked=bool(data.revoked), - ) + def test_nocache_bypasses_cache(run: Run, mock_api: Mock) -> None: + """list_share_grants(nocache=True) bypasses the cache and calls the API again.""" + first = [_make_grant_response(grant_id="grant-first")] + second = [_make_grant_response(grant_id="grant-second")] + mock_api.list_grants_v1_access_grants_get.side_effect = [first, second] - with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: - grant.revoke() + result1 = list(run.list_share_grants()) + result2 = list(run.list_share_grants(nocache=True)) - call_kw = mock_api.revoke_grant_v1_access_grants_grant_id_delete.call_args.kwargs - mock_api.revoke_grant_v1_access_grants_grant_id_delete.assert_called_once_with( - grant_id=_GRANT_ID, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, - ) - mock_clear.assert_called_once() - - -class TestCreateShareToken: - """Tests for Run.create_share_token().""" - - @pytest.mark.unit - @staticmethod - def test_creates_token_and_grant(run: Run, mock_api: Mock) -> None: - """create_share_token() creates the token, binds it via a grant, and returns ShareToken.""" - token_response = ShareTokenCreateResponse( - share_token_id=_TOKEN_ID, - share_token=_TOKEN_VALUE, - created_at=datetime(2024, 1, 1, tzinfo=UTC), - expires_at=None, - revoked=False, - ) - mock_api.create_share_token_v1_access_share_tokens_post.return_value = token_response - mock_api.create_grant_v1_access_grants_post.return_value = _make_grant( - subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID - ) - - with patch("aignostics.platform.resources.runs.operation_cache_clear") as mock_clear: - result = run.create_share_token() - - assert isinstance(result, ShareToken) - assert result.share_token_id == _TOKEN_ID - assert result.token == _TOKEN_VALUE - assert result.created_at == datetime(2024, 1, 1, tzinfo=UTC) - assert result.expires_at is None - mock_api.create_share_token_v1_access_share_tokens_post.assert_called_once() - grant_req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs[ - "grant_create_request" - ] - assert grant_req.resource_type == ResourceType.RUN - assert grant_req.resource_id == _RUN_ID - assert grant_req.subject_type == SubjectType.SHARE_TOKEN - assert grant_req.subject_id == _TOKEN_ID - assert grant_req.relation == GrantRelation.VIEWER - mock_clear.assert_called_once() + assert result1[0].grant_id == "grant-first" + assert result2[0].grant_id == "grant-second" + assert mock_api.list_grants_v1_access_grants_get.call_count == 2 @pytest.mark.unit @staticmethod - def test_passes_expires_at(run: Run, mock_api: Mock) -> None: - """create_share_token() forwards expires_at to the share-token creation request.""" - expires = datetime(2025, 12, 31, tzinfo=UTC) - token_response = ShareTokenCreateResponse( - share_token_id=_TOKEN_ID, - share_token=_TOKEN_VALUE, - created_at=datetime(2024, 1, 1, tzinfo=UTC), - expires_at=expires, - revoked=False, - ) - mock_api.create_share_token_v1_access_share_tokens_post.return_value = token_response - mock_api.create_grant_v1_access_grants_post.return_value = _make_grant( - subject_type=SubjectType.SHARE_TOKEN, subject_id=_TOKEN_ID - ) + def test_default_uses_cache_on_second_call(run: Run, mock_api: Mock) -> None: + """list_share_grants() without nocache returns cached result on the second call.""" + mock_api.list_grants_v1_access_grants_get.return_value = [_make_grant_response()] - with patch("aignostics.platform.resources.runs.operation_cache_clear"): - result = run.create_share_token(expires_at=expires) + list(run.list_share_grants()) + list(run.list_share_grants()) - token_req: ShareTokenCreateRequest = mock_api.create_share_token_v1_access_share_tokens_post.call_args.kwargs[ - "share_token_create_request" - ] - assert token_req.expires_at == expires - assert result.expires_at == expires + mock_api.list_grants_v1_access_grants_get.assert_called_once() -class TestShareTokenGrants: - """Tests for ShareToken.grants().""" +class TestRunGrantAccess: + """Tests for Run.grant_access().""" @pytest.mark.unit @staticmethod - def test_returns_run_grants(mock_api: Mock) -> None: - """grants() returns RunGrant objects for each grant associated with the token.""" - grant = GrantReadResponse( - grant_id=_GRANT_ID, - resource_type=ResourceType.RUN, - resource_id=_RUN_ID, - subject_type=SubjectType.SHARE_TOKEN, - subject_id=_TOKEN_ID, - relation=GrantRelation.VIEWER, - created_by="user-1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), - revoked=False, - ) - mock_api.list_grants_v1_access_grants_get.return_value = [grant] - token = ShareToken(mock_api, _TOKEN_ID) + def test_creates_grant_with_correct_request(run: Run, mock_api: Mock) -> None: + """grant_access() calls create_grant with the correct GrantCreateRequest fields.""" + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant_response() - result = list(token.grants()) + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + run.grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=_ORG_ID) - assert len(result) == 1 - assert isinstance(result[0], RunGrant) - assert result[0].grant_id == _GRANT_ID - assert result[0].run_id == _RUN_ID - assert result[0].relation == GrantRelation.VIEWER - call_kw = mock_api.list_grants_v1_access_grants_get.call_args.kwargs - mock_api.list_grants_v1_access_grants_get.assert_called_once_with( - subject_type=SubjectType.SHARE_TOKEN, - subject_id=_TOKEN_ID, - revoked=False, - page=1, - page_size=100, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, - ) + req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] + assert req.resource_type == ResourceType.RUN + assert req.resource_id == _RUN_ID + assert req.subject_type == SubjectType.ORGANIZATION_USER + assert req.subject_id == _ORG_ID + assert req.relation == GrantRelation.VIEWER @pytest.mark.unit @staticmethod - def test_returns_empty_list_when_none(mock_api: Mock) -> None: - """grants() returns an empty iterator when the API returns no grants.""" - mock_api.list_grants_v1_access_grants_get.return_value = [] - token = ShareToken(mock_api, _TOKEN_ID) + def test_returns_access_grant(run: Run, mock_api: Mock) -> None: + """grant_access() returns an AccessGrant built from the API response.""" + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant_response() + + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + result = run.grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=_ORG_ID) - assert list(token.grants()) == [] + assert isinstance(result, AccessGrant) + assert result.grant_id == _GRANT_ID + assert result.subject_id == _ORG_ID + assert result.relation == GrantRelation.VIEWER @pytest.mark.unit @staticmethod - def test_run_grant_revoke(mock_api: Mock) -> None: - """RunGrant.revoke() calls the revoke endpoint and clears the cache.""" - grant = RunGrant( - mock_api, - _GRANT_ID, - run_id=_RUN_ID, - relation=GrantRelation.VIEWER, - created_at=datetime(2024, 1, 1, tzinfo=UTC), - revoked=False, - ) + def test_clears_operation_cache(run: Run, mock_api: Mock) -> None: + """grant_access() clears the operation cache after creating the grant.""" + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant_response() - with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: - grant.revoke() + with patch("aignostics.platform.resources.runs.operation_cache_clear") as mock_clear: + run.grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=_ORG_ID) - call_kw = mock_api.revoke_grant_v1_access_grants_grant_id_delete.call_args.kwargs - mock_api.revoke_grant_v1_access_grants_grant_id_delete.assert_called_once_with( - grant_id=_GRANT_ID, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, - ) mock_clear.assert_called_once() - -class TestShareTokenRevoke: - """Tests for ShareToken.revoke() and Run.share_token() factory.""" - @pytest.mark.unit @staticmethod - def test_revoke_calls_api_and_clears_cache(mock_api: Mock) -> None: - """ShareToken.revoke() calls the revoke endpoint and clears the cache.""" - token = ShareToken(mock_api, _TOKEN_ID) - - with patch("aignostics.platform.resources.access.operation_cache_clear") as mock_clear: - token.revoke() - - call_kw = mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.call_args.kwargs - mock_api.revoke_share_token_v1_access_share_tokens_share_token_id_delete.assert_called_once_with( - share_token_id=_TOKEN_ID, - _request_timeout=call_kw["_request_timeout"], - _headers={"User-Agent": call_kw["_headers"]["User-Agent"]}, + def test_works_with_share_token_subject_type(run: Run, mock_api: Mock) -> None: + """grant_access() accepts SubjectType.SHARE_TOKEN and forwards it correctly.""" + token_id = "token-abc" # noqa: S105 + mock_api.create_grant_v1_access_grants_post.return_value = _make_grant_response( + subject_type=SubjectType.SHARE_TOKEN, subject_id=token_id ) - mock_clear.assert_called_once() - @pytest.mark.unit - @staticmethod - def test_run_share_token_factory(run: Run, mock_api: Mock) -> None: - """Run.share_token(id) returns a ShareToken handle without making an API call.""" - token = run.share_token(_TOKEN_ID) + with patch("aignostics.platform.resources.runs.operation_cache_clear"): + result = run.grant_access(subject_type=SubjectType.SHARE_TOKEN, subject_id=token_id) - assert isinstance(token, ShareToken) - assert token.share_token_id == _TOKEN_ID - mock_api.assert_not_called() + req: GrantCreateRequest = mock_api.create_grant_v1_access_grants_post.call_args.kwargs["grant_create_request"] + assert req.subject_type == SubjectType.SHARE_TOKEN + assert req.subject_id == token_id + assert isinstance(result, AccessGrant) From 429212245c8c11cf7a515e3f970b64350d0e60a3 Mon Sep 17 00:00:00 2001 From: dima-aignostics Date: Thu, 4 Jun 2026 16:41:18 +0200 Subject: [PATCH 03/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aignostics/platform/resources/runs.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 3660cd41e..b907fb472 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -692,8 +692,6 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: lambda: self._api.list_grants_v1_access_grants_get( resource_type=ResourceType.RUN, resource_id=self.run_id, - subject_type=subject_type, - subject_id=subject_id, revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, @@ -706,7 +704,15 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: api=self._api, **g.__dict__, ) - for g in paginate(lambda **kw: fetch_grant_page(nocache=nocache, **kw), page_size=page_size) + for g in paginate( + lambda **kw: fetch_grant_page( + nocache=nocache, + subject_type=subject_type, + subject_id=subject_id, + **kw, + ), + page_size=page_size, + ) ) def grant_access(self, subject_type: SubjectType, subject_id: str) -> AccessGrant: From 7f6ee1a9019b27eab47837d84054ca9b722d1e72 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 16:39:35 +0200 Subject: [PATCH 04/15] feat: add retry logic to grant_access Wraps the create_grant API call in a Retrying block with exponential backoff and jitter, consistent with list_share_grants and other methods. Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/platform/resources/access.py | 4 ++- src/aignostics/platform/resources/runs.py | 34 +++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index ca92cdc18..ff2aaadf2 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -238,12 +238,14 @@ def for_token_id(cls, share_token_id: str, cache_token: bool = True) -> "ShareTo """ from aignostics.platform._client import Client # noqa: PLC0415 - return Client.get_api_client(cache_token=cache_token).get_share_token_v1_access_share_tokens_share_token_id_get( + token = Client.get_api_client(cache_token=cache_token).get_share_token_v1_access_share_tokens_share_token_id_get( share_token_id=share_token_id, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) + return ShareToken(api=cls._api, **token.__dict__) + def list_share_grants(self, *, page_size: int = 100) -> Iterator[AccessGrant]: """List all active grants where this token is the subject. diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index b907fb472..a4173c92a 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -719,23 +719,33 @@ def grant_access(self, subject_type: SubjectType, subject_id: str) -> AccessGran """Share this run with all users in an organization. Args: + subject_type: The type of subject to grant access to. + subject_id: The ID of the subject to grant access to. Returns: - OrganizationGrant: The created grant. + AccessGrant: The created grant. Raises: - Exception: If the API request fails. + Exception: If the API request fails after all retries. """ - grant = self._api.create_grant_v1_access_grants_post( - grant_create_request=GrantCreateRequest( - resource_type=ResourceType.RUN, - resource_id=self.run_id, - subject_type=subject_type, - subject_id=subject_id, - relation=GrantRelation.VIEWER, - ), - _request_timeout=settings().run_timeout, - _headers={"User-Agent": user_agent()}, + grant = Retrying( + retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), + stop=stop_after_attempt(settings().run_retry_attempts), + wait=wait_exponential_jitter(initial=settings().run_retry_wait_min, max=settings().run_retry_wait_max), + before_sleep=_log_retry_attempt, + reraise=True, + )( + lambda: self._api.create_grant_v1_access_grants_post( + grant_create_request=GrantCreateRequest( + resource_type=ResourceType.RUN, + resource_id=self.run_id, + subject_type=subject_type, + subject_id=subject_id, + relation=GrantRelation.VIEWER, + ), + _request_timeout=settings().run_timeout, + _headers={"User-Agent": user_agent()}, + ) ) operation_cache_clear() return AccessGrant( From f668663150700765c1da82d548f6d295b5a20c45 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 17:38:28 +0200 Subject: [PATCH 05/15] Update CLI methods --- src/aignostics/application/_service.py | 40 ++++++++++------------- src/aignostics/platform/resources/runs.py | 6 ++-- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 3ddd3a3d9..69ad6b67d 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -27,16 +27,15 @@ InputArtifact, InputItem, NotFoundException, - OrganizationGrant, Run, RunData, RunOutput, RunState, - ShareToken, ) from aignostics.platform import Service as PlatformService from aignostics.utils import BaseService, Health, sanitize_path_component from aignostics.wsi import Service as WSIService +from aignx.codegen.models import SubjectType, GrantRelation from ._download import ( download_available_items, @@ -54,6 +53,7 @@ validate_due_date, validate_scheduling_constraints, ) +from ..platform.resources.access import AccessGrant, ShareToken has_qupath_extra = find_spec("ijson") if has_qupath_extra: @@ -1327,7 +1327,7 @@ def application_run_delete(self, run_id: str) -> None: def application_run_organization_grants( self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE - ) -> Iterator[OrganizationGrant]: + ) -> Iterator[AccessGrant]: """List active organization grants for a run. Args: @@ -1335,14 +1335,14 @@ def application_run_organization_grants( page_size (int): Number of grants per page. Defaults to max (100). Returns: - Iterator[OrganizationGrant]: Active organization grants. + Iterator[AccessGrant]: Active grants for this run. Raises: NotFoundException: If the run is not found. RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).organization_grants(page_size=page_size) + return self.application_run(run_id).list_share_grants(subject_type=SubjectType.ORGANIZATION_USER, relation=GrantRelation.VIEWER, page_size=page_size) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1369,7 +1369,7 @@ def application_run_share_tokens( RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).share_tokens(page_size=page_size) + return self.application_run(run_id).list_share_grants(subject_type=SubjectType.SHARE_TOKEN, relation=GrantRelation.VIEWER, page_size=page_size) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1380,24 +1380,23 @@ def application_run_share_tokens( raise RuntimeError(message) from e def application_run_share_with_organization( - self, run_id: str, organization_id: str | None = None - ) -> OrganizationGrant: + self, run_id: str + ) -> AccessGrant: """Share a run with all users in an organization. Args: run_id (str): The ID of the run. - organization_id (str | None): The organization ID to share with. - If None, the caller's organization ID is resolved via the /me endpoint. Returns: - OrganizationGrant: The created grant. + AccessGrant: The created grant. Raises: NotFoundException: If the run is not found. RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).share_with_organization(organization_id=organization_id) + organization_id = self._client.me().organization.id + return self.application_run(run_id).grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=organization_id) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1407,24 +1406,19 @@ def application_run_share_with_organization( logger.exception(message) raise RuntimeError(message) from e - def application_run_unshare_with_organization(self, run_id: str, organization_id: str | None = None) -> None: + def application_run_unshare_with_organization(self, run_id: str) -> None: """Revoke all active organization grants for a run. Args: run_id (str): The ID of the run. - organization_id (str | None): Only revoke grants for this organization ID. - If None, the caller's organization ID is resolved via the /me endpoint. Raises: NotFoundException: If the run is not found. RuntimeError: If the request fails unexpectedly. """ try: - run = self.application_run(run_id) - org_id = organization_id or self._get_platform_client().me().organization.id - for grant in run.organization_grants(nocache=True): - if grant.subject_id == org_id: - grant.revoke() + for grant in self.application_run_organization_grants(run_id): + grant.revoke() except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1448,7 +1442,9 @@ def application_run_create_share_token(self, run_id: str) -> ShareToken: RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).create_share_token() + share_token = self._client.share_tokens.create() + self.application_run(run_id).grant_access(subject_type=SubjectType.SHARE_TOKEN, subject_id=share_token.share_token_id) + return share_token except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1470,7 +1466,7 @@ def application_run_revoke_share_token(self, run_id: str, share_token_id: str) - RuntimeError: If the request fails unexpectedly. """ try: - self.application_run(run_id).share_token(share_token_id).revoke() + ShareToken.for_token_id(share_token_id).revoke() except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index a4173c92a..32132b271 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -661,7 +661,7 @@ def update_item_custom_metadata( operation_cache_clear() # Clear all caches since we updated a run def list_share_grants( - self, subject_type: SubjectType | None = None, subject_id: str | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False + self, subject_type: SubjectType | None = None, subject_id: str | None = None, relation: GrantRelation | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False ) -> Iterator[AccessGrant]: """List active organization grants for this run. @@ -670,7 +670,7 @@ def list_share_grants( nocache (bool): If True, bypass cache and fetch fresh data. Defaults to False. Returns: - Iterator[OrganizationGrant]: Active grants for organization_user and organization_admin subjects. + Iterator[ShareGrant]: Active grants for this run. Raises: ValueError: If page_size is greater than 100. @@ -692,6 +692,7 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: lambda: self._api.list_grants_v1_access_grants_get( resource_type=ResourceType.RUN, resource_id=self.run_id, + relation=relation, revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, @@ -709,6 +710,7 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: nocache=nocache, subject_type=subject_type, subject_id=subject_id, + relation=relation, **kw, ), page_size=page_size, From 762e673dc7eb1b45fdd300da53fb9357bc972ac3 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 20:44:39 +0200 Subject: [PATCH 06/15] feat: add expires_at to share token create and organization_id to org grants - Add --expires-at option to `application run share token create` CLI command; accepts ISO 8601 string, defaults to UTC if no timezone given - Add expires_at parameter to Service.application_run_create_share_token() - Promote organization_id from --option to positional [ORGANIZATION_ID] argument in both `share organization grant` and `share organization revoke` commands - Add organization_id parameter to application_run_unshare_with_organization() so revoke filters grants by org (defaults to authenticated user's own org) Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/application/_cli.py | 114 +++++++++++------- src/aignostics/application/_service.py | 53 ++++---- src/aignostics/platform/resources/access.py | 12 +- src/aignostics/platform/resources/runs.py | 3 +- .../platform/resources/access_test.py | 27 +++-- 5 files changed, 129 insertions(+), 80 deletions(-) diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 3be0e4bac..722f8ca80 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -5,6 +5,7 @@ import sys import time import zipfile +from datetime import UTC, datetime from pathlib import Path from typing import Annotated @@ -62,7 +63,7 @@ typer.Option( help="Optional soft due date to include with the run submission, ISO8601 format. " "The scheduler will try to complete the run by this date, taking the subscription tier" - "and available GPU resources into account." + "and available GPU resources into account.", ), ] @@ -204,7 +205,7 @@ def application_list( # noqa: C901 logger.exception(f"Failed to get application details for application '{app.application_id}'") console.print( f"[error]Error:[/error] Failed to get application details for application " - f"'{app.application_id}': {e}" + f"'{app.application_id}': {e}", ) continue console.print("[bold]Available Versions:[/bold]") @@ -228,7 +229,7 @@ def application_list( # noqa: C901 for app in apps: app_count += 1 console.print( - f"- [bold]{app.application_id}[/bold] - latest application version: `{app.latest_version or 'None'}`" + f"- [bold]{app.application_id}[/bold] - latest application version: `{app.latest_version or 'None'}`", ) if app_count == 0: @@ -294,8 +295,8 @@ def application_dump_schemata( # noqa: C901 if input_artifact.metadata_schema: file_path: Path = sanitize_path( Path( - destination / f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json" - ) + destination / f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json", + ), ) # type: ignore file_path.write_text(data=json.dumps(input_artifact.metadata_schema, indent=2), encoding="utf-8") created_files.append(file_path) @@ -305,14 +306,14 @@ def application_dump_schemata( # noqa: C901 file_path = sanitize_path( Path( destination - / f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json" - ) + / f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json", + ), ) # type: ignore file_path.write_text(data=json.dumps(output_artifact.metadata_schema, indent=2), encoding="utf-8") created_files.append(file_path) md_file_path: Path = sanitize_path( - Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.md") + Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.md"), ) # type: ignore with md_file_path.open("w", encoding="utf-8") as md_file: md_file.write(f"# Schemata for Aignostics Application {app.name}\n") @@ -322,19 +323,19 @@ def application_dump_schemata( # noqa: C901 for input_artifact in app_version.input_artifacts: md_file.write( f"- {input_artifact.name}: " - f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json\n" + f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json\n", ) md_file.write("\n## Output Artifacts\n") for output_artifact in app_version.output_artifacts: md_file.write( f"- {output_artifact.name}: " - f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json\n" + f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json\n", ) created_files.append(md_file_path) if zip: zip_filename = sanitize_path( - Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.zip") + Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.zip"), ) with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf: for file_path in created_files: @@ -399,7 +400,7 @@ def application_describe( # noqa: C901, PLR0912 logger.exception(f"Failed to get application version for '{application_id}', '{version.number}'") console.print( f"[error]Error:[/error] Failed to get application version for " - f"'{application_id}', '{version.number}': {e}" + f"'{application_id}', '{version.number}': {e}", ) sys.exit(1) @@ -599,7 +600,7 @@ def run_prepare( "Each mapping is of the form ':=,=,...'. " "The regular expression is matched against the external_id attribute of the entry. " "The key/value pairs are applied to the entry if the pattern matches. " - "You can use the mapping option multiple times to set values for multiple files. " + "You can use the mapping option multiple times to set values for multiple files. ", ), ] = None, ) -> None: @@ -711,7 +712,7 @@ def run_upload( # noqa: PLR0913, PLR0917 with Progress( TextColumn( f"[progress.description]Uploading from {metadata_csv_file} to " - f"{BucketService().get_bucket_protocol()}:/{BucketService().get_bucket_name()}/{upload_prefix}" + f"{BucketService().get_bucket_protocol()}:/{BucketService().get_bucket_name()}/{upload_prefix}", ), BarColumn(), TaskProgressColumn(), @@ -794,24 +795,24 @@ def run_submit( # noqa: PLR0913, PLR0917 try: app_version = Service().application_version( - application_id=application_id, application_version=application_version + application_id=application_id, application_version=application_version, ) except ValueError as e: logger.warning( - "Bad input to create run for application '{}' (version: '{}'): {}", application_id, application_version, e + "Bad input to create run for application '{}' (version: '{}'): {}", application_id, application_version, e, ) console.print( f"[warning]Warning:[/warning] Bad input to create run for application " - f"'{application_id} (version: {application_version})': {e}" + f"'{application_id} (version: {application_version})': {e}", ) sys.exit(2) except NotFoundException as e: logger.warning( - "Could not find application version '{}' (version: '{}'): {}", application_id, application_version, e + "Could not find application version '{}' (version: '{}'): {}", application_id, application_version, e, ) console.print( f"[warning]Warning:[/warning] Could not find application '{application_id} " - f"(version: {application_version})': {e}" + f"(version: {application_version})': {e}", ) sys.exit(2) except Exception as e: @@ -856,7 +857,7 @@ def run_submit( # noqa: PLR0913, PLR0917 ) console.print( f"Submitted run with id '{application_run.run_id}' for " - f"'{application_id} (version: {app_version.version_number})'." + f"'{application_id} (version: {app_version.version_number})'.", ) return application_run.run_id except ValueError as e: @@ -868,16 +869,16 @@ def run_submit( # noqa: PLR0913, PLR0917 ) console.print( f"[warning]Warning:[/warning] Bad input to create run for application " - f"'{application_id} (version: {app_version.version_number})': {e}" + f"'{application_id} (version: {app_version.version_number})': {e}", ) sys.exit(2) except Exception as e: logger.exception( - "Failed to create run for application '{}' (version: {})", application_id, app_version.version_number + "Failed to create run for application '{}' (version: {})", application_id, app_version.version_number, ) console.print( f"[error]Error:[/error] Failed to create run for application " - f"'{application_id} (version: {app_version.version_number})': {e}" + f"'{application_id} (version: {app_version.version_number})': {e}", ) sys.exit(1) @@ -984,7 +985,7 @@ def run_describe( print(json.dumps(run_data, indent=2, default=str)) else: retrieve_and_print_run_details( - run, hide_platform_queue_position=not user_info.is_internal_user, summarize=summarize + run, hide_platform_queue_position=not user_info.is_internal_user, summarize=summarize, ) logger.debug("Described run with ID '{}'", run_id) except NotFoundException: @@ -1220,7 +1221,7 @@ def run_cancel_by_filter( # noqa: C901, PLR0912, PLR0915 def run_update_metadata( run_id: Annotated[str, typer.Argument(..., help="Id of the run to update")], metadata_json: Annotated[ - str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')') + str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')'), ], ) -> None: """Update custom metadata for a run.""" @@ -1261,7 +1262,7 @@ def run_update_item_metadata( run_id: Annotated[str, typer.Argument(..., help="Id of the run containing the item")], external_id: Annotated[str, typer.Argument(..., help="External ID of the item to update")], metadata_json: Annotated[ - str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')') + str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')'), ], ) -> None: """Update custom metadata for an item in a run.""" @@ -1296,7 +1297,7 @@ def run_update_item_metadata( ) console.print( f"[warning]Warning:[/warning] Run ID '{run_id}' or item external ID '{external_id}' " - f"invalid or metadata invalid: {e}" + f"invalid or metadata invalid: {e}", ) sys.exit(2) except Exception as e: @@ -1307,7 +1308,7 @@ def run_update_item_metadata( ) console.print( f"[bold red]Error:[/bold red] Failed to update custom metadata for item '{external_id}' " - f"in run with ID '{run_id}': {e}" + f"in run with ID '{run_id}': {e}", ) sys.exit(1) @@ -1319,27 +1320,29 @@ def run_share_status( ) -> None: """Show sharing status: active organization grants and share tokens.""" try: - grants = list(Service().application_run_organization_grants(run_id)) + org_grants = list(Service().application_run_organization_grants(run_id)) tokens = list(Service().application_run_share_tokens(run_id)) + if format == "json": print( json.dumps( { - "organization_grants": [g.model_dump() for g in grants], + "organization_grants": [g.model_dump() for g in org_grants], "share_tokens": [t.model_dump() for t in tokens], }, indent=2, default=str, - ) + ), ) else: - console.print(f"[bold]Organization grants[/bold] ({len(grants)}):") - for g in grants: + console.print(f"[bold]Organization grants[/bold] ({len(org_grants)}):") + for g in org_grants: console.print(f" {g.grant_id} subject={g.subject_id} relation={g.relation.value}") + console.print(f"[bold]Share tokens[/bold] ({len(tokens)}):") for t in tokens: expires = t.expires_at.isoformat() if t.expires_at else "never" - created = t.created_at.isoformat() if t.created_at else "unknown" + created = t.created_at.isoformat() console.print(f" {t.share_token_id} created={created} expires={expires}") except NotFoundException: console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") @@ -1366,7 +1369,7 @@ def run_share_organization_list( for g in grants: console.print( f"{g.grant_id} subject={g.subject_id}" - f" relation={g.relation.value} created={g.created_at.isoformat()}" + f" relation={g.relation.value} created={g.created_at.isoformat()}", ) except NotFoundException: console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") @@ -1381,11 +1384,12 @@ def run_share_organization_list( def run_share_organization_grant( run_id: Annotated[str, typer.Argument(..., help="Id of the run to share")], organization_id: Annotated[ - str | None, typer.Option(help="Organization ID to share with (defaults to your own organization)") + str | None, + typer.Argument(help="Organization ID to share with (defaults to your own organization)"), ] = None, format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 ) -> None: - """Share a run with all users in your organization.""" + """Share a run with all users in an organization.""" try: grant = Service().application_run_share_with_organization(run_id, organization_id=organization_id) if format == "json": @@ -1405,10 +1409,11 @@ def run_share_organization_grant( def run_share_organization_revoke( run_id: Annotated[str, typer.Argument(..., help="Id of the run to unshare")], organization_id: Annotated[ - str | None, typer.Option(help="Organization ID to revoke access for (defaults to your own organization)") + str | None, + typer.Argument(help="Organization ID to revoke access for (defaults to your own organization)"), ] = None, ) -> None: - """Revoke all organization grants for a run.""" + """Revoke organization grants for a run.""" try: Service().application_run_unshare_with_organization(run_id, organization_id=organization_id) console.print(f"Organization access revoked for run '{run_id}'.") @@ -1429,6 +1434,7 @@ def run_share_token_list( """List active share tokens for a run.""" try: tokens = list(Service().application_run_share_tokens(run_id)) + if format == "json": print(json.dumps([t.model_dump() for t in tokens], indent=2, default=str)) else: @@ -1450,18 +1456,36 @@ def run_share_token_list( @share_token_app.command("create") def run_share_token_create( run_id: Annotated[str, typer.Argument(..., help="Id of the run to create a share token for")], + expires_at: Annotated[ + str | None, + typer.Option( + help="Expiry datetime in ISO 8601 format, e.g. '2026-12-31T23:59:59Z'. Omit for a non-expiring token.", + ), + ] = None, format: Annotated[str, typer.Option(help="Output format: 'text' (default) or 'json'")] = "text", # noqa: A002 ) -> None: """Create a share token for a run. The token value is shown only once.""" + expires_at_dt: datetime | None = None + if expires_at is not None: + try: + expires_at_dt = datetime.fromisoformat(expires_at) + if expires_at_dt.tzinfo is None: + expires_at_dt = expires_at_dt.replace(tzinfo=UTC) + except ValueError: + console.print( + f"[error]Error:[/error] Invalid --expires-at value '{expires_at}'. " + "Use ISO 8601 format, e.g. '2026-12-31T23:59:59Z'." + ) + sys.exit(1) try: - token = Service().application_run_create_share_token(run_id) + token = Service().application_run_create_share_token(run_id, expires_at=expires_at_dt) if format == "json": print(json.dumps(token.model_dump(), indent=2, default=str)) else: expires = token.expires_at.isoformat() if token.expires_at else "never" console.print(f"Share token created for run '{run_id}'.") console.print(f" Token ID : {token.share_token_id}") - console.print(f" Token : [bold]{token.token}[/bold]") + console.print(f" Token : [bold]{token.share_token}[/bold]") console.print(f" Expires : {expires}") console.print("[yellow]Save the token value — it will not be shown again.[/yellow]") except NotFoundException: @@ -1533,7 +1557,7 @@ def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917 "This option requires the QuPath extension for Launchpad: " 'start the Launchpad with `uvx --with "aignostics[qupath]" aignostics ...` \n' "This options requires installation of the QuPath application: " - 'Run uvx --with "aignostics[qupath]" aignostics qupath install' + 'Run uvx --with "aignostics[qupath]" aignostics qupath install', ), ] = False, ) -> None: @@ -1701,7 +1725,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 except Exception as e: logger.exception(f"Failed to download results of run with ID '{run_id}'") console.print( - f"[error]Error:[/error] Failed to download results of run with ID '{run_id}': {type(e).__name__}: {e}" + f"[error]Error:[/error] Failed to download results of run with ID '{run_id}': {type(e).__name__}: {e}", ) sys.exit(1) @@ -1819,7 +1843,7 @@ def application_version_document_describe( print(json.dumps({"error": "failed", "message": str(e)}), file=sys.stderr) else: console.print( - f"[error]Error:[/error] Failed to describe release document '{document_name}' for '{version_ref}': {e}" + f"[error]Error:[/error] Failed to describe release document '{document_name}' for '{version_ref}': {e}", ) sys.exit(1) @@ -1882,7 +1906,7 @@ def application_version_document_download( except Exception as e: logger.exception(f"Failed to download release document '{document_name}' for '{version_ref}'") console.print( - f"[error]Error:[/error] Failed to download release document '{document_name}' for '{version_ref}': {e}" + f"[error]Error:[/error] Failed to download release document '{document_name}' for '{version_ref}': {e}", ) sys.exit(1) diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 69ad6b67d..218d70176 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -12,6 +12,7 @@ import crc32c import requests +from aignx.codegen.models import GrantRelation, SubjectType from loguru import logger from aignostics.bucket import Service as BucketService @@ -35,8 +36,8 @@ from aignostics.platform import Service as PlatformService from aignostics.utils import BaseService, Health, sanitize_path_component from aignostics.wsi import Service as WSIService -from aignx.codegen.models import SubjectType, GrantRelation +from ..platform.resources.access import AccessGrant, ShareToken from ._download import ( download_available_items, download_url_to_file_with_progress, @@ -53,7 +54,6 @@ validate_due_date, validate_scheduling_constraints, ) -from ..platform.resources.access import AccessGrant, ShareToken has_qupath_extra = find_spec("ijson") if has_qupath_extra: @@ -491,7 +491,7 @@ def application_run_upload( # noqa: PLR0913, PLR0917 signed_upload_url, ) with ( - open(source_file_path, "rb") as f, + Path(source_file_path).open("rb") as f, ): def read_in_chunks( # noqa: PLR0913, PLR0917 @@ -923,7 +923,7 @@ def application_run_submit_from_metadata( # noqa: PLR0913, PLR0917 name=input_artifact_name, download_url=download_url, metadata=item_metadata, - ) + ), ], custom_metadata={ "sdk": { @@ -931,10 +931,10 @@ def application_run_submit_from_metadata( # noqa: PLR0913, PLR0917 "bucket_name": bucket_name, "object_key": object_key, "signed_download_url": download_url, - } - } + }, + }, }, - ) + ), ) logger.trace("Items for application run submission: {}", items) @@ -1326,7 +1326,7 @@ def application_run_delete(self, run_id: str) -> None: raise RuntimeError(message) from e def application_run_organization_grants( - self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE + self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, ) -> Iterator[AccessGrant]: """List active organization grants for a run. @@ -1342,7 +1342,7 @@ def application_run_organization_grants( RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).list_share_grants(subject_type=SubjectType.ORGANIZATION_USER, relation=GrantRelation.VIEWER, page_size=page_size) + return self.application_run(run_id).list_share_grants(subject_type=SubjectType.ORGANIZATION_USER, relation=[GrantRelation.VIEWER], page_size=page_size) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1353,7 +1353,7 @@ def application_run_organization_grants( raise RuntimeError(message) from e def application_run_share_tokens( - self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE + self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, ) -> Iterator[ShareToken]: """List active share tokens for a run. @@ -1369,7 +1369,7 @@ def application_run_share_tokens( RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).list_share_grants(subject_type=SubjectType.SHARE_TOKEN, relation=GrantRelation.VIEWER, page_size=page_size) + return self._get_platform_client().share_tokens.list(run_id=run_id) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1380,7 +1380,7 @@ def application_run_share_tokens( raise RuntimeError(message) from e def application_run_share_with_organization( - self, run_id: str + self, run_id: str, organization_id: str | None = None, ) -> AccessGrant: """Share a run with all users in an organization. @@ -1395,29 +1395,36 @@ def application_run_share_with_organization( RuntimeError: If the request fails unexpectedly. """ try: - organization_id = self._client.me().organization.id + organization_id = organization_id or self._get_platform_client().me().organization.id return self.application_run(run_id).grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=organization_id) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) raise NotFoundException(message) from e except Exception as e: - message = f"Failed to share run '{run_id}' with organization: {e}" + message = f"Failed to share run '{run_id}' with organization {organization_id} : {e}" logger.exception(message) raise RuntimeError(message) from e - def application_run_unshare_with_organization(self, run_id: str) -> None: - """Revoke all active organization grants for a run. + def application_run_unshare_with_organization(self, run_id: str, organization_id: str | None = None) -> None: + """Revoke active organization grants for a run. Args: run_id (str): The ID of the run. + organization_id (str | None): Organization whose grants to revoke. + Defaults to the authenticated user's own organization. Raises: NotFoundException: If the run is not found. RuntimeError: If the request fails unexpectedly. """ try: - for grant in self.application_run_organization_grants(run_id): + organization_id = organization_id or self._get_platform_client().me().organization.id + for grant in self.application_run(run_id).list_share_grants( + subject_type=SubjectType.ORGANIZATION_USER, + subject_id=organization_id, + relation=[GrantRelation.VIEWER], + ): grant.revoke() except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" @@ -1428,11 +1435,13 @@ def application_run_unshare_with_organization(self, run_id: str) -> None: logger.exception(message) raise RuntimeError(message) from e - def application_run_create_share_token(self, run_id: str) -> ShareToken: + def application_run_create_share_token(self, run_id: str, expires_at: datetime | None = None) -> ShareToken: """Create a share token for a run. Args: run_id (str): The ID of the run. + expires_at (datetime | None): Optional UTC datetime at which the token expires. + Pass ``None`` (default) for a token that never expires. Returns: ShareToken: The created token, including the one-time ``token`` value. @@ -1442,7 +1451,7 @@ def application_run_create_share_token(self, run_id: str) -> ShareToken: RuntimeError: If the request fails unexpectedly. """ try: - share_token = self._client.share_tokens.create() + share_token = self._get_platform_client().share_tokens.create(expires_at=expires_at) self.application_run(run_id).grant_access(subject_type=SubjectType.SHARE_TOKEN, subject_id=share_token.share_token_id) return share_token except NotFoundException as e: @@ -1624,7 +1633,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, item.external_id = str(local_path) # Update external_id so subsequent code uses the local path except Exception as e: logger.warning( - "Failed to download input slide from '{}' to '{}': {}", item.external_id, local_path, e + "Failed to download input slide from '{}' to '{}': {}", item.external_id, local_path, e, ) if qupath_project: @@ -1643,7 +1652,7 @@ def update_qupath_add_input_progress(qupath_add_input_progress: QuPathAddProgres continue image_paths.append(local_path.resolve()) added = QuPathService.add( - final_destination_directory / "qupath", image_paths, update_qupath_add_input_progress + final_destination_directory / "qupath", image_paths, update_qupath_add_input_progress, ) message = f"Added '{added}' input slides to QuPath project." logger.debug(message) @@ -1691,7 +1700,7 @@ def update_qupath_add_input_progress(qupath_add_input_progress: QuPathAddProgres break logger.trace( - "Run '{}' is in progress with status '{}', waiting for completion ...", run_id, run_details.state + "Run '{}' is in progress with status '{}', waiting for completion ...", run_id, run_details.state, ) progress.status = DownloadProgressState.WAITING update_progress(progress, download_progress_callable, download_progress_queue) diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index ff2aaadf2..53db39dd5 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -202,6 +202,7 @@ class ShareToken(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) _api: _AuthenticatedApi = PrivateAttr() + share_token_id: str revoked: bool created_at: datetime @@ -238,13 +239,14 @@ def for_token_id(cls, share_token_id: str, cache_token: bool = True) -> "ShareTo """ from aignostics.platform._client import Client # noqa: PLC0415 - token = Client.get_api_client(cache_token=cache_token).get_share_token_v1_access_share_tokens_share_token_id_get( + api = Client.get_api_client(cache_token=cache_token) + token = api.get_share_token_v1_access_share_tokens_share_token_id_get( share_token_id=share_token_id, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) - return ShareToken(api=cls._api, **token.__dict__) + return ShareToken(api=api, **token.__dict__) def list_share_grants(self, *, page_size: int = 100) -> Iterator[AccessGrant]: """List all active grants where this token is the subject. @@ -337,13 +339,15 @@ class ShareTokens(_AuthenticatedResource): def __init__(self, api: _AuthenticatedApi) -> None: # noqa: D107 super().__init__(api) - def list(self, *, nocache: bool = False, page_size: int = 100) -> Iterator[ShareToken]: + def list(self, *, run_id: str | None = None, nocache: bool = False, page_size: int = 100) -> Iterator[ShareToken]: """List all share tokens for the authenticated user. Results are cached for ``run_cache_ttl`` seconds and retried on transient network or server errors. Args: + run_id: Optional run ID to filter tokens by the run they are associated with. + Defaults to ``None`` (no filter). nocache: If ``True``, bypass the local cache and fetch fresh data from the API. The fetched result is still written to the cache. Defaults to ``False``. @@ -378,6 +382,8 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: lambda: [ ShareToken(api=self._api, **t.__dict__) for t in self._api.list_share_tokens_v1_access_share_tokens_get( + run_id=run_id, + revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, **kwargs, # pyright: ignore[reportArgumentType] diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 32132b271..01dc67348 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -661,7 +661,7 @@ def update_item_custom_metadata( operation_cache_clear() # Clear all caches since we updated a run def list_share_grants( - self, subject_type: SubjectType | None = None, subject_id: str | None = None, relation: GrantRelation | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False + self, subject_type: SubjectType | None = None, subject_id: str | None = None, relation: list[GrantRelation] | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False ) -> Iterator[AccessGrant]: """List active organization grants for this run. @@ -692,7 +692,6 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: lambda: self._api.list_grants_v1_access_grants_get( resource_type=ResourceType.RUN, resource_id=self.run_id, - relation=relation, revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, diff --git a/tests/aignostics/platform/resources/access_test.py b/tests/aignostics/platform/resources/access_test.py index d90c43720..3ae965cc5 100644 --- a/tests/aignostics/platform/resources/access_test.py +++ b/tests/aignostics/platform/resources/access_test.py @@ -139,7 +139,9 @@ class TestShareTokenForTokenId: @staticmethod def test_calls_api_with_token_id(mock_api: Mock) -> None: """for_token_id() calls the get_share_token endpoint with the given ID.""" - mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = ( + _make_share_token_read_response() + ) with patch("aignostics.platform._client.Client") as mock_client_cls: mock_client_cls.get_api_client.return_value = mock_api @@ -156,7 +158,9 @@ def test_calls_api_with_token_id(mock_api: Mock) -> None: @staticmethod def test_uses_cached_api_client_by_default(mock_api: Mock) -> None: """for_token_id() calls get_api_client with cache_token=True by default.""" - mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = ( + _make_share_token_read_response() + ) with patch("aignostics.platform._client.Client") as mock_client_cls: mock_client_cls.get_api_client.return_value = mock_api @@ -168,7 +172,9 @@ def test_uses_cached_api_client_by_default(mock_api: Mock) -> None: @staticmethod def test_cache_token_false_forwarded(mock_api: Mock) -> None: """for_token_id(cache_token=False) passes cache_token=False to get_api_client.""" - mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = Mock() + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = ( + _make_share_token_read_response() + ) with patch("aignostics.platform._client.Client") as mock_client_cls: mock_client_cls.get_api_client.return_value = mock_api @@ -178,16 +184,21 @@ def test_cache_token_false_forwarded(mock_api: Mock) -> None: @pytest.mark.unit @staticmethod - def test_returns_api_response(mock_api: Mock) -> None: - """for_token_id() returns the raw value from the API call.""" - sentinel = object() - mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = sentinel + def test_returns_share_token_with_correct_fields(mock_api: Mock) -> None: + """for_token_id() returns a ShareToken constructed from the API response.""" + mock_api.get_share_token_v1_access_share_tokens_share_token_id_get.return_value = ( + _make_share_token_read_response() + ) with patch("aignostics.platform._client.Client") as mock_client_cls: mock_client_cls.get_api_client.return_value = mock_api result = ShareToken.for_token_id(_TOKEN_ID) - assert result is sentinel + assert isinstance(result, ShareToken) + assert result.share_token_id == _TOKEN_ID + assert result.created_at == _CREATED_AT + assert result.revoked is False + assert result.share_token is None # Secret absent in read responses class TestShareTokenRevoke: From c4e64bea30571d789bdc3da276b5701da98e8f93 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Thu, 4 Jun 2026 20:48:56 +0200 Subject: [PATCH 07/15] Update package --- pyproject.toml | 2 +- src/aignostics/application/_cli.py | 64 ++++++++++----------- src/aignostics/application/_service.py | 57 ++++++++++++------ src/aignostics/platform/resources/access.py | 31 ++++------ src/aignostics/platform/resources/runs.py | 38 +++++++----- uv.lock | 8 +-- 6 files changed, 112 insertions(+), 88 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 796a1de07..6f21ff96b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ dependencies = [ "procrastinate>=3.5.3", "fastparquet>=2026.3.0,<2026.4.0; python_version < '3.14'", "pyarrow>=23.0.1,<24; python_version >= '3.14'", - "pyjwt[crypto]>=2.12.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475) + "pyjwt[crypto]>=2.13.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475) "python-dateutil>=2.9.0.post0,<3", # "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'", "requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0 diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 722f8ca80..0cd5aaf45 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -63,7 +63,7 @@ typer.Option( help="Optional soft due date to include with the run submission, ISO8601 format. " "The scheduler will try to complete the run by this date, taking the subscription tier" - "and available GPU resources into account.", + "and available GPU resources into account." ), ] @@ -205,7 +205,7 @@ def application_list( # noqa: C901 logger.exception(f"Failed to get application details for application '{app.application_id}'") console.print( f"[error]Error:[/error] Failed to get application details for application " - f"'{app.application_id}': {e}", + f"'{app.application_id}': {e}" ) continue console.print("[bold]Available Versions:[/bold]") @@ -229,7 +229,7 @@ def application_list( # noqa: C901 for app in apps: app_count += 1 console.print( - f"- [bold]{app.application_id}[/bold] - latest application version: `{app.latest_version or 'None'}`", + f"- [bold]{app.application_id}[/bold] - latest application version: `{app.latest_version or 'None'}`" ) if app_count == 0: @@ -295,8 +295,8 @@ def application_dump_schemata( # noqa: C901 if input_artifact.metadata_schema: file_path: Path = sanitize_path( Path( - destination / f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json", - ), + destination / f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json" + ) ) # type: ignore file_path.write_text(data=json.dumps(input_artifact.metadata_schema, indent=2), encoding="utf-8") created_files.append(file_path) @@ -306,14 +306,14 @@ def application_dump_schemata( # noqa: C901 file_path = sanitize_path( Path( destination - / f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json", - ), + / f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json" + ) ) # type: ignore file_path.write_text(data=json.dumps(output_artifact.metadata_schema, indent=2), encoding="utf-8") created_files.append(file_path) md_file_path: Path = sanitize_path( - Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.md"), + Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.md") ) # type: ignore with md_file_path.open("w", encoding="utf-8") as md_file: md_file.write(f"# Schemata for Aignostics Application {app.name}\n") @@ -323,19 +323,19 @@ def application_dump_schemata( # noqa: C901 for input_artifact in app_version.input_artifacts: md_file.write( f"- {input_artifact.name}: " - f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json\n", + f"{app.application_id}_{app_version.version_number}_input_{input_artifact.name}.json\n" ) md_file.write("\n## Output Artifacts\n") for output_artifact in app_version.output_artifacts: md_file.write( f"- {output_artifact.name}: " - f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json\n", + f"{app.application_id}_{app_version.version_number}_output_{output_artifact.name}.json\n" ) created_files.append(md_file_path) if zip: zip_filename = sanitize_path( - Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.zip"), + Path(destination / f"{app.application_id}_{app_version.version_number}_schemata.zip") ) with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf: for file_path in created_files: @@ -400,7 +400,7 @@ def application_describe( # noqa: C901, PLR0912 logger.exception(f"Failed to get application version for '{application_id}', '{version.number}'") console.print( f"[error]Error:[/error] Failed to get application version for " - f"'{application_id}', '{version.number}': {e}", + f"'{application_id}', '{version.number}': {e}" ) sys.exit(1) @@ -600,7 +600,7 @@ def run_prepare( "Each mapping is of the form ':=,=,...'. " "The regular expression is matched against the external_id attribute of the entry. " "The key/value pairs are applied to the entry if the pattern matches. " - "You can use the mapping option multiple times to set values for multiple files. ", + "You can use the mapping option multiple times to set values for multiple files. " ), ] = None, ) -> None: @@ -712,7 +712,7 @@ def run_upload( # noqa: PLR0913, PLR0917 with Progress( TextColumn( f"[progress.description]Uploading from {metadata_csv_file} to " - f"{BucketService().get_bucket_protocol()}:/{BucketService().get_bucket_name()}/{upload_prefix}", + f"{BucketService().get_bucket_protocol()}:/{BucketService().get_bucket_name()}/{upload_prefix}" ), BarColumn(), TaskProgressColumn(), @@ -795,24 +795,24 @@ def run_submit( # noqa: PLR0913, PLR0917 try: app_version = Service().application_version( - application_id=application_id, application_version=application_version, + application_id=application_id, application_version=application_version ) except ValueError as e: logger.warning( - "Bad input to create run for application '{}' (version: '{}'): {}", application_id, application_version, e, + "Bad input to create run for application '{}' (version: '{}'): {}", application_id, application_version, e ) console.print( f"[warning]Warning:[/warning] Bad input to create run for application " - f"'{application_id} (version: {application_version})': {e}", + f"'{application_id} (version: {application_version})': {e}" ) sys.exit(2) except NotFoundException as e: logger.warning( - "Could not find application version '{}' (version: '{}'): {}", application_id, application_version, e, + "Could not find application version '{}' (version: '{}'): {}", application_id, application_version, e ) console.print( f"[warning]Warning:[/warning] Could not find application '{application_id} " - f"(version: {application_version})': {e}", + f"(version: {application_version})': {e}" ) sys.exit(2) except Exception as e: @@ -857,7 +857,7 @@ def run_submit( # noqa: PLR0913, PLR0917 ) console.print( f"Submitted run with id '{application_run.run_id}' for " - f"'{application_id} (version: {app_version.version_number})'.", + f"'{application_id} (version: {app_version.version_number})'." ) return application_run.run_id except ValueError as e: @@ -869,16 +869,16 @@ def run_submit( # noqa: PLR0913, PLR0917 ) console.print( f"[warning]Warning:[/warning] Bad input to create run for application " - f"'{application_id} (version: {app_version.version_number})': {e}", + f"'{application_id} (version: {app_version.version_number})': {e}" ) sys.exit(2) except Exception as e: logger.exception( - "Failed to create run for application '{}' (version: {})", application_id, app_version.version_number, + "Failed to create run for application '{}' (version: {})", application_id, app_version.version_number ) console.print( f"[error]Error:[/error] Failed to create run for application " - f"'{application_id} (version: {app_version.version_number})': {e}", + f"'{application_id} (version: {app_version.version_number})': {e}" ) sys.exit(1) @@ -985,7 +985,7 @@ def run_describe( print(json.dumps(run_data, indent=2, default=str)) else: retrieve_and_print_run_details( - run, hide_platform_queue_position=not user_info.is_internal_user, summarize=summarize, + run, hide_platform_queue_position=not user_info.is_internal_user, summarize=summarize ) logger.debug("Described run with ID '{}'", run_id) except NotFoundException: @@ -1221,7 +1221,7 @@ def run_cancel_by_filter( # noqa: C901, PLR0912, PLR0915 def run_update_metadata( run_id: Annotated[str, typer.Argument(..., help="Id of the run to update")], metadata_json: Annotated[ - str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')'), + str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')') ], ) -> None: """Update custom metadata for a run.""" @@ -1262,7 +1262,7 @@ def run_update_item_metadata( run_id: Annotated[str, typer.Argument(..., help="Id of the run containing the item")], external_id: Annotated[str, typer.Argument(..., help="External ID of the item to update")], metadata_json: Annotated[ - str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')'), + str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')') ], ) -> None: """Update custom metadata for an item in a run.""" @@ -1297,7 +1297,7 @@ def run_update_item_metadata( ) console.print( f"[warning]Warning:[/warning] Run ID '{run_id}' or item external ID '{external_id}' " - f"invalid or metadata invalid: {e}", + f"invalid or metadata invalid: {e}" ) sys.exit(2) except Exception as e: @@ -1308,7 +1308,7 @@ def run_update_item_metadata( ) console.print( f"[bold red]Error:[/bold red] Failed to update custom metadata for item '{external_id}' " - f"in run with ID '{run_id}': {e}", + f"in run with ID '{run_id}': {e}" ) sys.exit(1) @@ -1557,7 +1557,7 @@ def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917 "This option requires the QuPath extension for Launchpad: " 'start the Launchpad with `uvx --with "aignostics[qupath]" aignostics ...` \n' "This options requires installation of the QuPath application: " - 'Run uvx --with "aignostics[qupath]" aignostics qupath install', + 'Run uvx --with "aignostics[qupath]" aignostics qupath install' ), ] = False, ) -> None: @@ -1725,7 +1725,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 except Exception as e: logger.exception(f"Failed to download results of run with ID '{run_id}'") console.print( - f"[error]Error:[/error] Failed to download results of run with ID '{run_id}': {type(e).__name__}: {e}", + f"[error]Error:[/error] Failed to download results of run with ID '{run_id}': {type(e).__name__}: {e}" ) sys.exit(1) @@ -1843,7 +1843,7 @@ def application_version_document_describe( print(json.dumps({"error": "failed", "message": str(e)}), file=sys.stderr) else: console.print( - f"[error]Error:[/error] Failed to describe release document '{document_name}' for '{version_ref}': {e}", + f"[error]Error:[/error] Failed to describe release document '{document_name}' for '{version_ref}': {e}" ) sys.exit(1) @@ -1906,7 +1906,7 @@ def application_version_document_download( except Exception as e: logger.exception(f"Failed to download release document '{document_name}' for '{version_ref}'") console.print( - f"[error]Error:[/error] Failed to download release document '{document_name}' for '{version_ref}': {e}", + f"[error]Error:[/error] Failed to download release document '{document_name}' for '{version_ref}': {e}" ) sys.exit(1) diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 218d70176..d3021e723 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -34,10 +34,10 @@ RunState, ) from aignostics.platform import Service as PlatformService +from aignostics.platform.resources.access import AccessGrant, ShareToken from aignostics.utils import BaseService, Health, sanitize_path_component from aignostics.wsi import Service as WSIService -from ..platform.resources.access import AccessGrant, ShareToken from ._download import ( download_available_items, download_url_to_file_with_progress, @@ -491,7 +491,7 @@ def application_run_upload( # noqa: PLR0913, PLR0917 signed_upload_url, ) with ( - Path(source_file_path).open("rb") as f, + open(source_file_path, "rb") as f, ): def read_in_chunks( # noqa: PLR0913, PLR0917 @@ -923,7 +923,7 @@ def application_run_submit_from_metadata( # noqa: PLR0913, PLR0917 name=input_artifact_name, download_url=download_url, metadata=item_metadata, - ), + ) ], custom_metadata={ "sdk": { @@ -931,10 +931,10 @@ def application_run_submit_from_metadata( # noqa: PLR0913, PLR0917 "bucket_name": bucket_name, "object_key": object_key, "signed_download_url": download_url, - }, - }, + } + } }, - ), + ) ) logger.trace("Items for application run submission: {}", items) @@ -1326,7 +1326,9 @@ def application_run_delete(self, run_id: str) -> None: raise RuntimeError(message) from e def application_run_organization_grants( - self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, + self, + run_id: str, + page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, ) -> Iterator[AccessGrant]: """List active organization grants for a run. @@ -1342,7 +1344,11 @@ def application_run_organization_grants( RuntimeError: If the request fails unexpectedly. """ try: - return self.application_run(run_id).list_share_grants(subject_type=SubjectType.ORGANIZATION_USER, relation=[GrantRelation.VIEWER], page_size=page_size) + return self.application_run(run_id).list_share_grants( + subject_type=SubjectType.ORGANIZATION_USER, + relation=[GrantRelation.VIEWER], + page_size=page_size, + ) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1353,7 +1359,9 @@ def application_run_organization_grants( raise RuntimeError(message) from e def application_run_share_tokens( - self, run_id: str, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, + self, + run_id: str, + page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, ) -> Iterator[ShareToken]: """List active share tokens for a run. @@ -1369,7 +1377,7 @@ def application_run_share_tokens( RuntimeError: If the request fails unexpectedly. """ try: - return self._get_platform_client().share_tokens.list(run_id=run_id) + return self._get_platform_client().share_tokens.list(run_id=run_id, page_size=page_size) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1380,12 +1388,16 @@ def application_run_share_tokens( raise RuntimeError(message) from e def application_run_share_with_organization( - self, run_id: str, organization_id: str | None = None, + self, + run_id: str, + organization_id: str | None = None, ) -> AccessGrant: """Share a run with all users in an organization. Args: run_id (str): The ID of the run. + organization_id (str | None): The organization to share with. Defaults to + the authenticated user's own organization. Returns: AccessGrant: The created grant. @@ -1396,7 +1408,10 @@ def application_run_share_with_organization( """ try: organization_id = organization_id or self._get_platform_client().me().organization.id - return self.application_run(run_id).grant_access(subject_type=SubjectType.ORGANIZATION_USER, subject_id=organization_id) + return self.application_run(run_id).grant_access( + subject_type=SubjectType.ORGANIZATION_USER, + subject_id=organization_id, + ) except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) @@ -1444,7 +1459,7 @@ def application_run_create_share_token(self, run_id: str, expires_at: datetime | Pass ``None`` (default) for a token that never expires. Returns: - ShareToken: The created token, including the one-time ``token`` value. + ShareToken: The created token. Access the one-time secret via ``share_token``. Raises: NotFoundException: If the run is not found. @@ -1452,7 +1467,10 @@ def application_run_create_share_token(self, run_id: str, expires_at: datetime | """ try: share_token = self._get_platform_client().share_tokens.create(expires_at=expires_at) - self.application_run(run_id).grant_access(subject_type=SubjectType.SHARE_TOKEN, subject_id=share_token.share_token_id) + self.application_run(run_id).grant_access( + subject_type=SubjectType.SHARE_TOKEN, + subject_id=share_token.share_token_id, + ) return share_token except NotFoundException as e: message = f"Application run with ID '{run_id}' not found: {e}" @@ -1463,7 +1481,8 @@ def application_run_create_share_token(self, run_id: str, expires_at: datetime | logger.exception(message) raise RuntimeError(message) from e - def application_run_revoke_share_token(self, run_id: str, share_token_id: str) -> None: + @staticmethod + def application_run_revoke_share_token(run_id: str, share_token_id: str) -> None: """Revoke a share token for a run. Args: @@ -1477,7 +1496,7 @@ def application_run_revoke_share_token(self, run_id: str, share_token_id: str) - try: ShareToken.for_token_id(share_token_id).revoke() except NotFoundException as e: - message = f"Application run with ID '{run_id}' not found: {e}" + message = f"Share token with ID '{share_token_id}' not found: {e}" logger.warning(message) raise NotFoundException(message) from e except Exception as e: @@ -1633,7 +1652,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, item.external_id = str(local_path) # Update external_id so subsequent code uses the local path except Exception as e: logger.warning( - "Failed to download input slide from '{}' to '{}': {}", item.external_id, local_path, e, + "Failed to download input slide from '{}' to '{}': {}", item.external_id, local_path, e ) if qupath_project: @@ -1652,7 +1671,7 @@ def update_qupath_add_input_progress(qupath_add_input_progress: QuPathAddProgres continue image_paths.append(local_path.resolve()) added = QuPathService.add( - final_destination_directory / "qupath", image_paths, update_qupath_add_input_progress, + final_destination_directory / "qupath", image_paths, update_qupath_add_input_progress ) message = f"Added '{added}' input slides to QuPath project." logger.debug(message) @@ -1700,7 +1719,7 @@ def update_qupath_add_input_progress(qupath_add_input_progress: QuPathAddProgres break logger.trace( - "Run '{}' is in progress with status '{}', waiting for completion ...", run_id, run_details.state, + "Run '{}' is in progress with status '{}', waiting for completion ...", run_id, run_details.state ) progress.status = DownloadProgressState.WAITING update_progress(progress, download_progress_callable, download_progress_queue) diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index 53db39dd5..f4ee092fc 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -48,6 +48,7 @@ print(t.share_token_id, t.expires_at) token.revoke() """ + import builtins from collections.abc import Iterator from datetime import datetime @@ -151,13 +152,16 @@ def for_grant_id(cls, grant_id: str, cache_token: bool = True) -> "AccessGrant": """ from aignostics.platform._client import Client # noqa: PLC0415 - return Client.get_api_client( - cache_token=cache_token).get_grant_v1_access_grants_grant_id_get( + api = Client.get_api_client(cache_token=cache_token) + + grant = api.get_grant_v1_access_grants_grant_id_get( grant_id=grant_id, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) + return cls(api=api, **grant.__dict__) + class ShareToken(BaseModel): """A share token that can be used to grant access to platform resources. @@ -184,8 +188,8 @@ class ShareToken(BaseModel): # Create a token and note the secret — it won't be retrievable later token = client.share_tokens.create() - secret = token.share_token # store or transmit this once - token_id = token.share_token_id # stable ID for revocation + secret = token.share_token # store or transmit this once + token_id = token.share_token_id # stable ID for revocation # Fetch the token record later (secret is gone) fetched = ShareToken.for_token_id(token_id) @@ -284,13 +288,7 @@ def fetch_page(**kwargs: object) -> list[GrantReadResponse]: ), ) - return ( - AccessGrant( - api=self._api, - **g.__dict__ - ) - for g in paginate(fetch_page, page_size=page_size) - ) + return (AccessGrant(api=self._api, **g.__dict__) for g in paginate(fetch_page, page_size=page_size)) def revoke(self) -> None: """Revoke this share token, invalidating all grants associated with it. @@ -428,17 +426,12 @@ def create( token = client.share_tokens.create( expires_at=datetime.now(timezone.utc) + timedelta(hours=24), ) - secret = token.share_token # transmit to the intended recipient + secret = token.share_token # transmit to the intended recipient """ share_token = self._api.create_share_token_v1_access_share_tokens_post( - share_token_create_request=ShareTokenCreateRequest( - expires_at=expires_at - ), + share_token_create_request=ShareTokenCreateRequest(expires_at=expires_at), _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) - return ShareToken( - api=self._api, - **share_token.__dict__ - ) + return ShareToken(api=self._api, **share_token.__dict__) diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 01dc67348..a2097db64 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -661,16 +661,27 @@ def update_item_custom_metadata( operation_cache_clear() # Clear all caches since we updated a run def list_share_grants( - self, subject_type: SubjectType | None = None, subject_id: str | None = None, relation: list[GrantRelation] | None = None, page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, nocache: bool = False + self, + subject_type: SubjectType | None = None, + subject_id: str | None = None, + relation: list[GrantRelation] | None = None, + page_size: int = LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, + nocache: bool = False, ) -> Iterator[AccessGrant]: - """List active organization grants for this run. + """List active access grants for this run. + + Supports optional filtering by subject type, subject ID, and relation. Args: - page_size (int): Number of grants per page. Defaults to max (100). - nocache (bool): If True, bypass cache and fetch fresh data. Defaults to False. + subject_type: Filter by subject type (e.g. ``ORGANIZATION_USER``, ``SHARE_TOKEN``). + Defaults to ``None`` (no filter). + subject_id: Filter by subject ID. Defaults to ``None``. + relation: Filter by relation type(s). Defaults to ``None``. + page_size: Number of grants per page. Defaults to max (100). + nocache: If ``True``, bypass cache and fetch fresh data. Defaults to ``False``. Returns: - Iterator[ShareGrant]: Active grants for this run. + Iterator[AccessGrant]: Active grants for this run. Raises: ValueError: If page_size is greater than 100. @@ -680,8 +691,10 @@ def list_share_grants( message = f"page_size must be <= {LIST_APPLICATION_RUNS_MAX_PAGE_SIZE}, but got {page_size}" raise ValueError(message) + run_id = self.run_id # capture explicitly so it enters the cache key as an arg + @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: + def fetch_grant_page(cached_run_id: str, **kwargs: object) -> list[GrantReadResponse]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(settings().run_retry_attempts), @@ -691,7 +704,7 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: )( lambda: self._api.list_grants_v1_access_grants_get( resource_type=ResourceType.RUN, - resource_id=self.run_id, + resource_id=cached_run_id, revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, @@ -706,6 +719,7 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: ) for g in paginate( lambda **kw: fetch_grant_page( + run_id, nocache=nocache, subject_type=subject_type, subject_id=subject_id, @@ -717,10 +731,11 @@ def fetch_grant_page(**kwargs: object) -> list[GrantReadResponse]: ) def grant_access(self, subject_type: SubjectType, subject_id: str) -> AccessGrant: - """Share this run with all users in an organization. + """Grant a subject VIEWER access to this run. Args: - subject_type: The type of subject to grant access to. + subject_type: The type of subject to grant access to (e.g. + ``ORGANIZATION_USER``, ``SHARE_TOKEN``). subject_id: The ID of the subject to grant access to. Returns: @@ -749,10 +764,7 @@ def grant_access(self, subject_type: SubjectType, subject_id: str) -> AccessGran ) ) operation_cache_clear() - return AccessGrant( - api=self._api, - **grant.__dict__ - ) + return AccessGrant(api=self._api, **grant.__dict__) def __str__(self) -> str: """Returns a string representation of the application run. diff --git a/uv.lock b/uv.lock index 8d31c8666..0ec709653 100644 --- a/uv.lock +++ b/uv.lock @@ -224,7 +224,7 @@ requires-dist = [ { name = "pydicom", specifier = ">=3.0.2" }, { name = "pygments", specifier = ">=2.20.0" }, { name = "pyinstaller", marker = "extra == 'pyinstaller'", specifier = ">=6.14.0,<7" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0,<3" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, { name = "python-multipart", specifier = ">=0.0.26" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311,<312" }, @@ -5728,11 +5728,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] From 77167fb865f38105ced0ae7a77af7f20e73fd90b Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Fri, 5 Jun 2026 15:40:59 +0200 Subject: [PATCH 08/15] Update expected test results for e2e --- tests/constants_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/constants_test.py b/tests/constants_test.py index 8ace5dbfa..854df9da5 100644 --- a/tests/constants_test.py +++ b/tests/constants_test.py @@ -85,6 +85,8 @@ ("tissue_qc_parquet_polygons.parquet", 39435, 10), ("tissue_segmentation_parquet_polygons.parquet", 117509, 10), ("cell_classification_parquet_polygons.parquet", 1985592, 10), + ("cell_detection_parquet_polygons.parquet", 1978790, 10), + ("cell_detection_parquet_centers.parquet", 657740, 10), ] SPOT_0_EXPECTED_CELLS_CLASSIFIED = (39798, 10) @@ -101,6 +103,8 @@ ("tissue_qc_parquet_polygons.parquet", 29087, 10), ("tissue_segmentation_parquet_polygons.parquet", 56563, 10), ("cell_classification_parquet_polygons.parquet", 562536, 10), + ("cell_detection_parquet_polygons.parquet", 1985614, 0), + ("cell_detection_parquet_centers.parquet", 657740, 0), ] match os.getenv("AIGNOSTICS_PLATFORM_ENVIRONMENT", "production"): From 0a5ff6304d623547bc607b15c0d564977ff8cef5 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 8 Jun 2026 09:54:40 +0200 Subject: [PATCH 09/15] test: add tests for run sharing CLI commands and fix cache key bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 34 integration tests covering all run share CLI subcommands (status, org list/grant/revoke, token list/create/revoke) including success, not-found, and error paths with both text and JSON output - Add 15 unit tests for the 6 new service sharing methods - Fix ShareTokens.list() cache key isolation bug: run_id was captured from closure scope and not included in the @cached_operation key, causing queries for different run IDs to share cache entries - Fix --expires-at Z suffix parsing: remove unnecessary .replace("Z", "+00:00") workaround since Python ≥ 3.11 handles the Z UTC designator natively in datetime.fromisoformat() Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/platform/resources/access.py | 5 +- tests/aignostics/application/cli_test.py | 486 +++++++++++++++++++ tests/aignostics/application/service_test.py | 234 +++++++++ 3 files changed, 723 insertions(+), 2 deletions(-) diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index f4ee092fc..df138fa60 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -369,7 +369,7 @@ def list(self, *, run_id: str | None = None, nocache: bool = False, page_size: i """ @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: + def list_data_with_retry(cached_run_id: str | None, **kwargs: object) -> builtins.list[ShareToken]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(settings().run_retry_attempts), @@ -380,7 +380,7 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: lambda: [ ShareToken(api=self._api, **t.__dict__) for t in self._api.list_share_tokens_v1_access_share_tokens_get( - run_id=run_id, + run_id=cached_run_id, revoked=False, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, @@ -391,6 +391,7 @@ def list_data_with_retry(**kwargs: object) -> builtins.list[ShareToken]: return paginate( lambda **kwargs: list_data_with_retry( + run_id, nocache=nocache, **kwargs, ), diff --git a/tests/aignostics/application/cli_test.py b/tests/aignostics/application/cli_test.py index 567e0f29c..b00ae7f4a 100644 --- a/tests/aignostics/application/cli_test.py +++ b/tests/aignostics/application/cli_test.py @@ -2182,3 +2182,489 @@ def test_cli_application_version_document_download_failed(runner: CliRunner, tmp output = normalize_output(result.output) assert "Failed to download release document" in output assert DOCUMENT_TEST_FAILURE_MESSAGE in output + + +# ───────────────────────────────────────────────────────────────────────────── +# run share status +# ───────────────────────────────────────────────────────────────────────────── + +APPLICATION_CLI_SERVICE_PATCH_TARGET = "aignostics.application._cli.Service" + + +def _make_mock_grant( + grant_id: str = "grant-001", + subject_id: str = "org-abc", + relation_value: str = "VIEWER", + created_at: datetime | None = None, +) -> MagicMock: + grant = MagicMock() + grant.grant_id = grant_id + grant.subject_id = subject_id + grant.relation = MagicMock() + grant.relation.value = relation_value + grant.created_at = created_at or datetime(2025, 1, 1, tzinfo=UTC) + grant.model_dump.return_value = {"grant_id": grant_id, "subject_id": subject_id} + return grant + + +def _make_mock_token( + share_token_id: str = "tok-001", # noqa: S107 + share_token: str = "secret-value", # noqa: S107 + created_at: datetime | None = None, + expires_at: datetime | None = None, +) -> MagicMock: + token = MagicMock() + token.share_token_id = share_token_id + token.share_token = share_token + token.created_at = created_at or datetime(2025, 1, 1, tzinfo=UTC) + token.expires_at = expires_at + token.model_dump.return_value = {"share_token_id": share_token_id} + return token + + +@pytest.mark.integration +def test_cli_run_share_status_empty_text(runner: CliRunner) -> None: + """Share status prints section headers even when there are no grants or tokens.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([]) + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([]) + result = runner.invoke(cli, ["application", "run", "share", "status", "run-001"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "Organization grants" in output + assert "Share tokens" in output + + +@pytest.mark.integration +def test_cli_run_share_status_with_data_text(runner: CliRunner) -> None: + """Share status renders grant and token IDs in text mode.""" + grant = _make_mock_grant() + token = _make_mock_token() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([grant]) + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([token]) + result = runner.invoke(cli, ["application", "run", "share", "status", "run-001"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "grant-001" in output + assert "tok-001" in output + + +@pytest.mark.integration +def test_cli_run_share_status_json(runner: CliRunner) -> None: + """Share status --format json returns parseable JSON with both sections.""" + grant = _make_mock_grant() + token = _make_mock_token() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([grant]) + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([token]) + result = runner.invoke(cli, ["application", "run", "share", "status", "run-001", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert "organization_grants" in data + assert "share_tokens" in data + assert data["organization_grants"][0]["grant_id"] == "grant-001" + assert data["share_tokens"][0]["share_token_id"] == "tok-001" # noqa: S105 + + +@pytest.mark.integration +def test_cli_run_share_status_not_found(runner: CliRunner) -> None: + """Share status exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "status", "bad-run"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_status_error(runner: CliRunner) -> None: + """Share status exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.side_effect = RuntimeError("boom") + result = runner.invoke(cli, ["application", "run", "share", "status", "run-001"]) + assert result.exit_code == 1 + assert "Failed to retrieve share status" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share organization list +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_organization_list_empty_text(runner: CliRunner) -> None: + """Organization list prints 'No active organization grants' when empty.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([]) + result = runner.invoke(cli, ["application", "run", "share", "organization", "list", "run-001"]) + assert result.exit_code == 0 + assert "No active organization grants" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_share_organization_list_with_data_text(runner: CliRunner) -> None: + """Organization list renders grant details in text mode.""" + grant = _make_mock_grant(grant_id="grant-xyz", subject_id="org-123") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([grant]) + result = runner.invoke(cli, ["application", "run", "share", "organization", "list", "run-001"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "grant-xyz" in output + assert "org-123" in output + + +@pytest.mark.integration +def test_cli_run_share_organization_list_json(runner: CliRunner) -> None: + """Organization list --format json returns a JSON array.""" + grant = _make_mock_grant() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.return_value = iter([grant]) + result = runner.invoke( + cli, ["application", "run", "share", "organization", "list", "run-001", "--format", "json"] + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["grant_id"] == "grant-001" + + +@pytest.mark.integration +def test_cli_run_share_organization_list_not_found(runner: CliRunner) -> None: + """Organization list exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "organization", "list", "bad-run"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_organization_list_error(runner: CliRunner) -> None: + """Organization list exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_organization_grants.side_effect = RuntimeError("kaboom") + result = runner.invoke(cli, ["application", "run", "share", "organization", "list", "run-001"]) + assert result.exit_code == 1 + assert "Failed to list organization grants" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share organization grant +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_organization_grant_text(runner: CliRunner) -> None: + """Organization grant prints confirmation in text mode.""" + grant = _make_mock_grant(grant_id="grant-new") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_with_organization.return_value = grant + result = runner.invoke(cli, ["application", "run", "share", "organization", "grant", "run-001", "org-abc"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "run-001" in output + assert "grant-new" in output + + +@pytest.mark.integration +def test_cli_run_share_organization_grant_text_no_org(runner: CliRunner) -> None: + """Organization grant without explicit org_id delegates to default organization.""" + grant = _make_mock_grant(grant_id="grant-default") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_with_organization.return_value = grant + result = runner.invoke(cli, ["application", "run", "share", "organization", "grant", "run-001"]) + assert result.exit_code == 0 + mock_svc_cls.return_value.application_run_share_with_organization.assert_called_once_with( + "run-001", organization_id=None + ) + + +@pytest.mark.integration +def test_cli_run_share_organization_grant_json(runner: CliRunner) -> None: + """Organization grant --format json returns parseable JSON.""" + grant = _make_mock_grant() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_with_organization.return_value = grant + result = runner.invoke( + cli, + ["application", "run", "share", "organization", "grant", "run-001", "org-abc", "--format", "json"], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["grant_id"] == "grant-001" + + +@pytest.mark.integration +def test_cli_run_share_organization_grant_not_found(runner: CliRunner) -> None: + """Organization grant exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_with_organization.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "organization", "grant", "bad-run", "org-abc"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_organization_grant_error(runner: CliRunner) -> None: + """Organization grant exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_with_organization.side_effect = RuntimeError("fail") + result = runner.invoke(cli, ["application", "run", "share", "organization", "grant", "run-001", "org-abc"]) + assert result.exit_code == 1 + assert "Failed to share run" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share organization revoke +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_organization_revoke_success(runner: CliRunner) -> None: + """Organization revoke prints confirmation.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_unshare_with_organization.return_value = None + result = runner.invoke(cli, ["application", "run", "share", "organization", "revoke", "run-001"]) + assert result.exit_code == 0 + assert "revoked" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_organization_revoke_with_org_id(runner: CliRunner) -> None: + """Organization revoke passes explicit org_id to the service.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_unshare_with_organization.return_value = None + result = runner.invoke(cli, ["application", "run", "share", "organization", "revoke", "run-001", "org-xyz"]) + assert result.exit_code == 0 + mock_svc_cls.return_value.application_run_unshare_with_organization.assert_called_once_with( + "run-001", organization_id="org-xyz" + ) + + +@pytest.mark.integration +def test_cli_run_share_organization_revoke_not_found(runner: CliRunner) -> None: + """Organization revoke exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_unshare_with_organization.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "organization", "revoke", "bad-run"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_organization_revoke_error(runner: CliRunner) -> None: + """Organization revoke exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_unshare_with_organization.side_effect = RuntimeError("fail") + result = runner.invoke(cli, ["application", "run", "share", "organization", "revoke", "run-001"]) + assert result.exit_code == 1 + assert "Failed to revoke organization access" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share token list +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_token_list_empty_text(runner: CliRunner) -> None: + """Token list prints 'No active share tokens' when empty.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([]) + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "run-001"]) + assert result.exit_code == 0 + assert "No active share tokens" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_share_token_list_with_data_text(runner: CliRunner) -> None: + """Token list renders token IDs in text mode.""" + token = _make_mock_token(share_token_id="tok-xyz") # noqa: S106 + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([token]) + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "run-001"]) + assert result.exit_code == 0 + assert "tok-xyz" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_share_token_list_with_expiry(runner: CliRunner) -> None: + """Token list renders expiry date when set.""" + expires = datetime(2026, 12, 31, 23, 59, 59, tzinfo=UTC) + token = _make_mock_token(expires_at=expires) + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([token]) + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "run-001"]) + assert result.exit_code == 0 + assert "2026" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_share_token_list_json(runner: CliRunner) -> None: + """Token list --format json returns a JSON array.""" + token = _make_mock_token() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.return_value = iter([token]) + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "run-001", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["share_token_id"] == "tok-001" # noqa: S105 + + +@pytest.mark.integration +def test_cli_run_share_token_list_not_found(runner: CliRunner) -> None: + """Token list exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "bad-run"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_token_list_error(runner: CliRunner) -> None: + """Token list exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_share_tokens.side_effect = RuntimeError("fail") + result = runner.invoke(cli, ["application", "run", "share", "token", "list", "run-001"]) + assert result.exit_code == 1 + assert "Failed to list share tokens" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share token create +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_token_create_text(runner: CliRunner) -> None: + """Token create prints token ID and secret once in text mode.""" + token = _make_mock_token(share_token_id="tok-new", share_token="s3cr3t") # noqa: S106 + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_create_share_token.return_value = token + result = runner.invoke(cli, ["application", "run", "share", "token", "create", "run-001"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "tok-new" in output + assert "s3cr3t" in output + assert "Save the token value" in output + + +@pytest.mark.integration +def test_cli_run_share_token_create_with_expiry(runner: CliRunner) -> None: + """Token create passes parsed expiry datetime to the service.""" + token = _make_mock_token(expires_at=datetime(2026, 12, 31, 23, 59, 59, tzinfo=UTC)) + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_create_share_token.return_value = token + result = runner.invoke( + cli, + [ + "application", + "run", + "share", + "token", + "create", + "run-001", + "--expires-at", + "2026-12-31T23:59:59Z", + ], + ) + assert result.exit_code == 0 + call_kwargs = mock_svc_cls.return_value.application_run_create_share_token.call_args + assert call_kwargs is not None + assert call_kwargs[1]["expires_at"] is not None or call_kwargs[0][1] is not None + + +@pytest.mark.integration +def test_cli_run_share_token_create_json(runner: CliRunner) -> None: + """Token create --format json returns parseable JSON.""" + token = _make_mock_token() + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_create_share_token.return_value = token + result = runner.invoke(cli, ["application", "run", "share", "token", "create", "run-001", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["share_token_id"] == "tok-001" # noqa: S105 + + +@pytest.mark.integration +def test_cli_run_share_token_create_invalid_expiry(runner: CliRunner) -> None: + """Token create exits 1 when --expires-at is not valid ISO 8601.""" + result = runner.invoke( + cli, + ["application", "run", "share", "token", "create", "run-001", "--expires-at", "not-a-date"], + ) + assert result.exit_code == 1 + assert "Invalid --expires-at" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_share_token_create_not_found(runner: CliRunner) -> None: + """Token create exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_create_share_token.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "token", "create", "bad-run"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_token_create_error(runner: CliRunner) -> None: + """Token create exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_create_share_token.side_effect = RuntimeError("fail") + result = runner.invoke(cli, ["application", "run", "share", "token", "create", "run-001"]) + assert result.exit_code == 1 + assert "Failed to create share token" in normalize_output(result.output) + + +# ───────────────────────────────────────────────────────────────────────────── +# run share token revoke +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.integration +def test_cli_run_share_token_revoke_success(runner: CliRunner) -> None: + """Token revoke prints confirmation.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_revoke_share_token.return_value = None + result = runner.invoke(cli, ["application", "run", "share", "token", "revoke", "run-001", "tok-001"]) + assert result.exit_code == 0 + output = normalize_output(result.output) + assert "tok-001" in output + assert "revoked" in output.lower() + + +@pytest.mark.integration +def test_cli_run_share_token_revoke_not_found(runner: CliRunner) -> None: + """Token revoke exits 2 when the run does not exist.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_revoke_share_token.side_effect = ApiNotFound( + status=404, reason="Not Found" + ) + result = runner.invoke(cli, ["application", "run", "share", "token", "revoke", "bad-run", "tok-001"]) + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_share_token_revoke_error(runner: CliRunner) -> None: + """Token revoke exits 1 on an unexpected error.""" + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_revoke_share_token.side_effect = RuntimeError("fail") + result = runner.invoke(cli, ["application", "run", "share", "token", "revoke", "run-001", "tok-001"]) + assert result.exit_code == 1 + assert "Failed to revoke share token" in normalize_output(result.output) diff --git a/tests/aignostics/application/service_test.py b/tests/aignostics/application/service_test.py index ea023455b..52b7e678d 100644 --- a/tests/aignostics/application/service_test.py +++ b/tests/aignostics/application/service_test.py @@ -565,3 +565,237 @@ def test_application_run_update_item_custom_metadata_not_found(mock_get_client: with pytest.raises(NotFoundException, match="not found"): service.application_run_update_item_custom_metadata("run-123", "invalid-item-id", {"key": "value"}) + + +# ───────────────────────────────────────────────────────────────────────────── +# run sharing service methods +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_organization_grants_success(mock_get_client: MagicMock) -> None: + """organization_grants delegates to Run.list_share_grants with org filter.""" + mock_grant = MagicMock() + mock_run = MagicMock() + mock_run.list_share_grants.return_value = iter([mock_grant]) + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + result = list(ApplicationService().application_run_organization_grants("run-123")) + + assert result == [mock_grant] + mock_client.run.assert_called_once_with("run-123") + mock_run.list_share_grants.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_organization_grants_not_found(mock_get_client: MagicMock) -> None: + """organization_grants re-raises NotFoundException.""" + mock_run = MagicMock() + mock_run.list_share_grants.side_effect = NotFoundException("not found") + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + with pytest.raises(NotFoundException, match="not found"): + list(ApplicationService().application_run_organization_grants("run-123")) + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_organization_grants_error(mock_get_client: MagicMock) -> None: + """organization_grants wraps unexpected errors in RuntimeError.""" + mock_run = MagicMock() + mock_run.list_share_grants.side_effect = RuntimeError("boom") + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + with pytest.raises(RuntimeError, match="boom"): + list(ApplicationService().application_run_organization_grants("run-123")) + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_share_tokens_success(mock_get_client: MagicMock) -> None: + """share_tokens delegates to ShareTokens.list with run_id filter.""" + mock_token = MagicMock() + mock_client = MagicMock() + mock_client.share_tokens.list.return_value = iter([mock_token]) + mock_get_client.return_value = mock_client + + result = list(ApplicationService().application_run_share_tokens("run-123")) + + assert result == [mock_token] + mock_client.share_tokens.list.assert_called_once_with(run_id="run-123", page_size=100) + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_share_tokens_not_found(mock_get_client: MagicMock) -> None: + """share_tokens re-raises NotFoundException.""" + mock_client = MagicMock() + mock_client.share_tokens.list.side_effect = NotFoundException("not found") + mock_get_client.return_value = mock_client + + with pytest.raises(NotFoundException, match="not found"): + list(ApplicationService().application_run_share_tokens("run-123")) + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_share_with_organization_explicit_org(mock_get_client: MagicMock) -> None: + """share_with_organization calls grant_access with the given org_id.""" + mock_grant = MagicMock() + mock_run = MagicMock() + mock_run.grant_access.return_value = mock_grant + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + result = ApplicationService().application_run_share_with_organization("run-123", organization_id="org-abc") + + assert result is mock_grant + mock_client.me.assert_not_called() + mock_run.grant_access.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_share_with_organization_defaults_to_own_org(mock_get_client: MagicMock) -> None: + """share_with_organization fetches own org_id when none is provided.""" + mock_grant = MagicMock() + mock_run = MagicMock() + mock_run.grant_access.return_value = mock_grant + mock_me = MagicMock() + mock_me.organization.id = "own-org" + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_client.me.return_value = mock_me + mock_get_client.return_value = mock_client + + result = ApplicationService().application_run_share_with_organization("run-123", organization_id=None) + + assert result is mock_grant + mock_client.me.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_share_with_organization_not_found(mock_get_client: MagicMock) -> None: + """share_with_organization re-raises NotFoundException.""" + mock_run = MagicMock() + mock_run.grant_access.side_effect = NotFoundException("not found") + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + with pytest.raises(NotFoundException, match="not found"): + ApplicationService().application_run_share_with_organization("run-123", organization_id="org-abc") + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_unshare_with_organization_revokes_grants(mock_get_client: MagicMock) -> None: + """unshare_with_organization revokes all matching grants.""" + mock_grant = MagicMock() + mock_run = MagicMock() + mock_run.list_share_grants.return_value = iter([mock_grant]) + mock_me = MagicMock() + mock_me.organization.id = "own-org" + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_client.me.return_value = mock_me + mock_get_client.return_value = mock_client + + ApplicationService().application_run_unshare_with_organization("run-123", organization_id=None) + + mock_grant.revoke.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_unshare_with_organization_not_found(mock_get_client: MagicMock) -> None: + """unshare_with_organization re-raises NotFoundException.""" + mock_run = MagicMock() + mock_run.list_share_grants.side_effect = NotFoundException("not found") + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + with pytest.raises(NotFoundException, match="not found"): + ApplicationService().application_run_unshare_with_organization("run-123", organization_id="org-abc") + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_create_share_token_success(mock_get_client: MagicMock) -> None: + """create_share_token creates a token and grants it access to the run.""" + mock_token = MagicMock() + mock_token.share_token_id = "tok-001" # noqa: S105 + mock_run = MagicMock() + mock_client = MagicMock() + mock_client.share_tokens.create.return_value = mock_token + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + result = ApplicationService().application_run_create_share_token("run-123") + + assert result is mock_token + mock_client.share_tokens.create.assert_called_once_with(expires_at=None) + mock_run.grant_access.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_create_share_token_with_expiry(mock_get_client: MagicMock) -> None: + """create_share_token passes expiry datetime through to ShareTokens.create.""" + mock_token = MagicMock() + mock_run = MagicMock() + mock_client = MagicMock() + mock_client.share_tokens.create.return_value = mock_token + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client + + expiry = datetime(2026, 12, 31, 23, 59, 59, tzinfo=UTC) + ApplicationService().application_run_create_share_token("run-123", expires_at=expiry) + + mock_client.share_tokens.create.assert_called_once_with(expires_at=expiry) + + +@pytest.mark.unit +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_create_share_token_not_found(mock_get_client: MagicMock) -> None: + """create_share_token re-raises NotFoundException.""" + mock_client = MagicMock() + mock_client.share_tokens.create.side_effect = NotFoundException("not found") + mock_get_client.return_value = mock_client + + with pytest.raises(NotFoundException, match="not found"): + ApplicationService().application_run_create_share_token("run-123") + + +@pytest.mark.unit +@patch("aignostics.platform.resources.access.ShareToken.for_token_id") +def test_application_run_revoke_share_token_success(mock_for_token_id: MagicMock) -> None: + """revoke_share_token fetches the token and calls revoke().""" + mock_token = MagicMock() + mock_for_token_id.return_value = mock_token + + ApplicationService.application_run_revoke_share_token("run-123", "tok-001") + + mock_for_token_id.assert_called_once_with("tok-001") + mock_token.revoke.assert_called_once() + + +@pytest.mark.unit +@patch("aignostics.platform.resources.access.ShareToken.for_token_id") +def test_application_run_revoke_share_token_not_found(mock_for_token_id: MagicMock) -> None: + """revoke_share_token re-raises NotFoundException when token is missing.""" + mock_for_token_id.side_effect = NotFoundException("not found") + + with pytest.raises(NotFoundException, match="not found"): + ApplicationService.application_run_revoke_share_token("run-123", "tok-missing") From ece8b9186b01e33b0fe629359dd4f5b295248bea Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 8 Jun 2026 10:01:09 +0200 Subject: [PATCH 10/15] Update pip package --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f21ff96b..bafa38c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -215,7 +215,7 @@ dev = [ "watchdog>=6.0.0,<7", # Transitive overrides # WARNING: one cannot negate or downgrade a dependency required here. use override-dependencies for that. - "pip>=26.1", # CVE-2025-8869 (Medium, >=25.3); CVE-2026-3219 (Medium, >=26.1, released 2026-04-26 via pypa/pip#13870) + "pip>=26.1.2", # CVE-2025-8869 (Medium, >=25.3); CVE-2026-3219 (Medium, >=26.1, released 2026-04-26 via pypa/pip#13870) "uv>=0.11.6", # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64 (>=0.9.7); GHSA-pjjw-68hj-v9mw (>=0.11.6, Renovate #536) "fonttools>=4.60.2", # CVE-2025-66034 (GHSA-768j-98cg-p3fv), dep of matplotlib "virtualenv>=20.36.1", # pypa/virtualenv#3013 TOCTOU in app_data/lock dir; bundles filelock>=3.20.1 for CVE-2025-68146; transitive via nox/pre-commit diff --git a/uv.lock b/uv.lock index 0ec709653..25e8ef6ef 100644 --- a/uv.lock +++ b/uv.lock @@ -264,7 +264,7 @@ dev = [ { name = "mypy", specifier = ">=1.19.0,<2" }, { name = "myst-parser", specifier = ">=5,<6" }, { name = "nox", extras = ["uv"], specifier = ">=2025.11.12" }, - { name = "pip", specifier = ">=26.1" }, + { name = "pip", specifier = ">=26.1.2" }, { name = "pip-audit", specifier = ">=2.10.0,<3" }, { name = "pip-licenses", git = "https://github.com/neXenio/pip-licenses.git?rev=master" }, { name = "pre-commit", specifier = ">=4.5.0,<5" }, @@ -5020,11 +5020,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.1" +version = "26.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] [[package]] From afd5dcd0c471427e0dca7f8fb890047c6c77976d Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 8 Jun 2026 10:20:34 +0200 Subject: [PATCH 11/15] fix: address PR review comments on share token error messaging - Fix extra space before colon in share-with-organization error message - Fix docstring for application_run_revoke_share_token: NotFoundException is raised when the share token is not found, not the run - Fix run_share_token_revoke CLI handler to print the exception message instead of a hardcoded "Run ... not found" string, so users see the correct context (token ID not found vs run ID not found) Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/application/_cli.py | 4 ++-- src/aignostics/application/_service.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 0cd5aaf45..bd6b09c0b 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -1506,8 +1506,8 @@ def run_share_token_revoke( try: Service().application_run_revoke_share_token(run_id, token_id) console.print(f"Share token '{token_id}' revoked for run '{run_id}'.") - except NotFoundException: - console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") + except NotFoundException as e: + console.print(f"[warning]Warning:[/warning] {e}") sys.exit(2) except Exception as e: logger.exception("Failed to revoke share token '{}' for run '{}'", token_id, run_id) diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index d3021e723..609ca9442 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -1417,7 +1417,7 @@ def application_run_share_with_organization( logger.warning(message) raise NotFoundException(message) from e except Exception as e: - message = f"Failed to share run '{run_id}' with organization {organization_id} : {e}" + message = f"Failed to share run '{run_id}' with organization {organization_id}: {e}" logger.exception(message) raise RuntimeError(message) from e @@ -1490,7 +1490,7 @@ def application_run_revoke_share_token(run_id: str, share_token_id: str) -> None share_token_id (str): The ID of the share token to revoke. Raises: - NotFoundException: If the run is not found. + NotFoundException: If the share token is not found. RuntimeError: If the request fails unexpectedly. """ try: From cc3c58f60dcb9a9ebee9ebb1cdff8a4f6e5c5ebb Mon Sep 17 00:00:00 2001 From: dima-aignostics Date: Mon, 8 Jun 2026 11:14:29 +0200 Subject: [PATCH 12/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aignostics/application/_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index bd6b09c0b..ae6ee777c 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -1466,9 +1466,9 @@ def run_share_token_create( ) -> None: """Create a share token for a run. The token value is shown only once.""" expires_at_dt: datetime | None = None - if expires_at is not None: try: - expires_at_dt = datetime.fromisoformat(expires_at) + expires_at_normalized = expires_at.replace("Z", "+00:00") if expires_at.endswith("Z") else expires_at + expires_at_dt = datetime.fromisoformat(expires_at_normalized) if expires_at_dt.tzinfo is None: expires_at_dt = expires_at_dt.replace(tzinfo=UTC) except ValueError: From cd096a9486e18cb497f62828c8dbb35dd243df67 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 8 Jun 2026 11:14:51 +0200 Subject: [PATCH 13/15] fix: revoke run-token grant instead of whole share token application_run_revoke_share_token now finds the SHARE_TOKEN grant on the specific run and revokes that grant, leaving the token itself intact for any other runs it may be shared with. Raises NotFoundException when no grant for the token exists on the run. Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/application/_service.py | 32 +++++++++++------ tests/aignostics/application/service_test.py | 36 ++++++++++++-------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 609ca9442..a47b27abd 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -1481,28 +1481,40 @@ def application_run_create_share_token(self, run_id: str, expires_at: datetime | logger.exception(message) raise RuntimeError(message) from e - @staticmethod - def application_run_revoke_share_token(run_id: str, share_token_id: str) -> None: - """Revoke a share token for a run. + def application_run_revoke_share_token(self, run_id: str, share_token_id: str) -> None: + """Revoke the grant giving a share token access to a run. + + Removes the token's access to this specific run without invalidating + the token itself; the token may still be valid for other runs. Args: run_id (str): The ID of the run. - share_token_id (str): The ID of the share token to revoke. + share_token_id (str): The ID of the share token whose grant to revoke. Raises: - NotFoundException: If the share token is not found. + NotFoundException: If the run is not found or no grant exists for + the token on this run. RuntimeError: If the request fails unexpectedly. """ try: - ShareToken.for_token_id(share_token_id).revoke() - except NotFoundException as e: - message = f"Share token with ID '{share_token_id}' not found: {e}" - logger.warning(message) - raise NotFoundException(message) from e + grants = list( + self.application_run(run_id).list_share_grants( + subject_type=SubjectType.SHARE_TOKEN, + subject_id=share_token_id, + ) + ) + for grant in grants: + grant.revoke() + except NotFoundException: + raise except Exception as e: message = f"Failed to revoke share token '{share_token_id}' for run '{run_id}': {e}" logger.exception(message) raise RuntimeError(message) from e + if not grants: + message = f"No grant found for share token '{share_token_id}' on run '{run_id}'" + logger.warning(message) + raise NotFoundException(message) @staticmethod def application_run_download_static( # noqa: PLR0913, PLR0917 diff --git a/tests/aignostics/application/service_test.py b/tests/aignostics/application/service_test.py index 52b7e678d..23a0e404e 100644 --- a/tests/aignostics/application/service_test.py +++ b/tests/aignostics/application/service_test.py @@ -779,23 +779,31 @@ def test_application_run_create_share_token_not_found(mock_get_client: MagicMock @pytest.mark.unit -@patch("aignostics.platform.resources.access.ShareToken.for_token_id") -def test_application_run_revoke_share_token_success(mock_for_token_id: MagicMock) -> None: - """revoke_share_token fetches the token and calls revoke().""" - mock_token = MagicMock() - mock_for_token_id.return_value = mock_token +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_revoke_share_token_success(mock_get_client: MagicMock) -> None: + """revoke_share_token finds the grant on the run and revokes it.""" + mock_grant = MagicMock() + mock_run = MagicMock() + mock_run.list_share_grants.return_value = iter([mock_grant]) + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client - ApplicationService.application_run_revoke_share_token("run-123", "tok-001") + ApplicationService().application_run_revoke_share_token("run-123", "tok-001") - mock_for_token_id.assert_called_once_with("tok-001") - mock_token.revoke.assert_called_once() + mock_run.list_share_grants.assert_called_once() + mock_grant.revoke.assert_called_once() @pytest.mark.unit -@patch("aignostics.platform.resources.access.ShareToken.for_token_id") -def test_application_run_revoke_share_token_not_found(mock_for_token_id: MagicMock) -> None: - """revoke_share_token re-raises NotFoundException when token is missing.""" - mock_for_token_id.side_effect = NotFoundException("not found") +@patch("aignostics.application._service.Service._get_platform_client") +def test_application_run_revoke_share_token_not_found(mock_get_client: MagicMock) -> None: + """revoke_share_token raises NotFoundException when no grant exists for the token.""" + mock_run = MagicMock() + mock_run.list_share_grants.return_value = iter([]) + mock_client = MagicMock() + mock_client.run.return_value = mock_run + mock_get_client.return_value = mock_client - with pytest.raises(NotFoundException, match="not found"): - ApplicationService.application_run_revoke_share_token("run-123", "tok-missing") + with pytest.raises(NotFoundException, match="No grant found"): + ApplicationService().application_run_revoke_share_token("run-123", "tok-missing") From f5b55672fd49ab60af0f7521d7a74752ccab5953 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 8 Jun 2026 11:23:09 +0200 Subject: [PATCH 14/15] feat: expose resource_type, resource_id, created_by on AccessGrant GrantReadResponse carries these three fields but AccessGrant was silently dropping them. Adding them lets callers (e.g. ShareToken. list_share_grants()) inspect which resource a grant applies to and who created it, without having to go back to the raw codegen model. Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics/platform/resources/access.py | 8 ++++++++ tests/aignostics/platform/resources/access_test.py | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/src/aignostics/platform/resources/access.py b/src/aignostics/platform/resources/access.py index df138fa60..bd43715be 100644 --- a/src/aignostics/platform/resources/access.py +++ b/src/aignostics/platform/resources/access.py @@ -57,6 +57,7 @@ from aignx.codegen.models import ( GrantReadResponse, GrantRelation, + ResourceType, ShareTokenCreateRequest, SubjectType, ) @@ -82,10 +83,13 @@ class AccessGrant(BaseModel): Attributes: grant_id: Unique identifier for this grant. + resource_type: Type of the resource this grant applies to (e.g. ``RUN``). + resource_id: Identifier of the resource (e.g. the run ID). subject_id: Identifier of the entity that was granted access. subject_type: Category of the subject (``ORGANIZATION_ADMIN``, ``ORGANIZATION_USER``, or ``SHARE_TOKEN``). relation: Level of access granted (currently always ``VIEWER``). + created_by: ID of the user who created this grant. created_at: UTC timestamp when the grant was created. revoked: ``True`` if the grant has already been revoked. @@ -102,9 +106,12 @@ class AccessGrant(BaseModel): _api: _AuthenticatedApi = PrivateAttr() grant_id: str + resource_type: ResourceType + resource_id: str subject_id: str subject_type: SubjectType relation: GrantRelation + created_by: str created_at: datetime revoked: bool @@ -434,5 +441,6 @@ def create( _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) + operation_cache_clear() return ShareToken(api=self._api, **share_token.__dict__) diff --git a/tests/aignostics/platform/resources/access_test.py b/tests/aignostics/platform/resources/access_test.py index 3ae965cc5..448fdfb1a 100644 --- a/tests/aignostics/platform/resources/access_test.py +++ b/tests/aignostics/platform/resources/access_test.py @@ -95,9 +95,12 @@ def test_revoke_calls_api_with_grant_id(mock_api: Mock) -> None: grant = AccessGrant( api=mock_api, grant_id=_GRANT_ID, + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, subject_id=_SUBJECT_ID, subject_type=SubjectType.ORGANIZATION_USER, relation=GrantRelation.VIEWER, + created_by="user-1", created_at=_CREATED_AT, revoked=False, ) @@ -119,9 +122,12 @@ def test_revoke_clears_operation_cache(mock_api: Mock) -> None: grant = AccessGrant( api=mock_api, grant_id=_GRANT_ID, + resource_type=ResourceType.RUN, + resource_id=_RUN_ID, subject_id=_SUBJECT_ID, subject_type=SubjectType.ORGANIZATION_USER, relation=GrantRelation.VIEWER, + created_by="user-1", created_at=_CREATED_AT, revoked=False, ) From cb211855e0830807a72d5991c039aa305b3da29a Mon Sep 17 00:00:00 2001 From: dima-aignostics Date: Mon, 8 Jun 2026 11:26:54 +0200 Subject: [PATCH 15/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aignostics/application/_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index ae6ee777c..b0bf24b95 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -1466,6 +1466,7 @@ def run_share_token_create( ) -> None: """Create a share token for a run. The token value is shown only once.""" expires_at_dt: datetime | None = None + if expires_at is not None: try: expires_at_normalized = expires_at.replace("Z", "+00:00") if expires_at.endswith("Z") else expires_at expires_at_dt = datetime.fromisoformat(expires_at_normalized)