From b6976e92df0cb1868f934f166f31c1bc17482db5 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 12:05:06 -0700 Subject: [PATCH 1/2] feat: SDK API token auto-refresh (GL-1618) Trade the long-lived bootstrap API token for short-lived working tokens that rotate automatically, per Phase 2 of the GL-1709 API Token Security Plan. - Regenerate the OpenAPI client for the new /v1/api-tokens endpoints (list, create, delete, and get-by-snippet). - Add groundlight.token_refresh.TokenRefresher: per-token disk cache slots (0600), an OS advisory file lock plus atomic writes for multi-process coordination, a daily-mint background daemon thread, and cleanup of the previous token after a grace window. - Wire it into Groundlight.__init__ and add close() / context-manager support. - Look up the current token's name by scanning the paginated token list for a matching raw_key_snippet (temporary). Include an unused by-snippet lookup that will replace it once GET /v1/api-tokens/by-snippet is deployed. - Gracefully fall back to the provided token where the endpoints are not yet deployed, so the SDK keeps working during the staged rollout. Co-authored-by: Cursor --- generated/.openapi-generator/FILES | 15 + generated/README.md | 8 + generated/docs/ApiToken.md | 16 + generated/docs/ApiTokenCreateResponse.md | 18 + generated/docs/ApiTokenRequest.md | 13 + generated/docs/ApiTokensApi.md | 325 +++++++++++++ generated/docs/PaginatedApiTokenList.md | 15 + .../api/api_tokens_api.py | 431 ++++++++++++++++++ .../apis/__init__.py | 1 + .../model/api_token.py | 300 ++++++++++++ .../model/api_token_create_response.py | 307 +++++++++++++ .../model/api_token_request.py | 286 ++++++++++++ .../model/paginated_api_token_list.py | 302 ++++++++++++ .../models/__init__.py | 4 + generated/model.py | 56 ++- generated/test/test_api_token.py | 35 ++ .../test/test_api_token_create_response.py | 35 ++ generated/test/test_api_token_request.py | 35 ++ generated/test/test_api_tokens_api.py | 44 ++ .../test/test_paginated_api_token_list.py | 38 ++ pyproject.toml | 2 + spec/public-api.yaml | 212 +++++++++ src/groundlight/client.py | 20 + src/groundlight/token_refresh.py | 363 +++++++++++++++ test/unit/test_token_refresh.py | 349 ++++++++++++++ 25 files changed, 3229 insertions(+), 1 deletion(-) create mode 100644 generated/docs/ApiToken.md create mode 100644 generated/docs/ApiTokenCreateResponse.md create mode 100644 generated/docs/ApiTokenRequest.md create mode 100644 generated/docs/ApiTokensApi.md create mode 100644 generated/docs/PaginatedApiTokenList.md create mode 100644 generated/groundlight_openapi_client/api/api_tokens_api.py create mode 100644 generated/groundlight_openapi_client/model/api_token.py create mode 100644 generated/groundlight_openapi_client/model/api_token_create_response.py create mode 100644 generated/groundlight_openapi_client/model/api_token_request.py create mode 100644 generated/groundlight_openapi_client/model/paginated_api_token_list.py create mode 100644 generated/test/test_api_token.py create mode 100644 generated/test/test_api_token_create_response.py create mode 100644 generated/test/test_api_token_request.py create mode 100644 generated/test/test_api_tokens_api.py create mode 100644 generated/test/test_paginated_api_token_list.py create mode 100644 src/groundlight/token_refresh.py create mode 100644 test/unit/test_token_refresh.py diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index 27394696..6b7e6c01 100644 --- a/generated/.openapi-generator/FILES +++ b/generated/.openapi-generator/FILES @@ -6,6 +6,10 @@ docs/ActionList.md docs/ActionsApi.md docs/AllNotes.md docs/AnnotationsRequestedEnum.md +docs/ApiToken.md +docs/ApiTokenCreateResponse.md +docs/ApiTokenRequest.md +docs/ApiTokensApi.md docs/BBoxGeometry.md docs/BBoxGeometryRequest.md docs/BinaryClassificationResult.md @@ -52,6 +56,7 @@ docs/Note.md docs/NoteRequest.md docs/NotesApi.md docs/NullEnum.md +docs/PaginatedApiTokenList.md docs/PaginatedDetectorList.md docs/PaginatedImageQueryList.md docs/PaginatedMLPipelineList.md @@ -82,6 +87,7 @@ git_push.sh groundlight_openapi_client/__init__.py groundlight_openapi_client/api/__init__.py groundlight_openapi_client/api/actions_api.py +groundlight_openapi_client/api/api_tokens_api.py groundlight_openapi_client/api/detector_groups_api.py groundlight_openapi_client/api/detector_reset_api.py groundlight_openapi_client/api/detectors_api.py @@ -102,6 +108,9 @@ groundlight_openapi_client/model/action.py groundlight_openapi_client/model/action_list.py groundlight_openapi_client/model/all_notes.py groundlight_openapi_client/model/annotations_requested_enum.py +groundlight_openapi_client/model/api_token.py +groundlight_openapi_client/model/api_token_create_response.py +groundlight_openapi_client/model/api_token_request.py groundlight_openapi_client/model/b_box_geometry.py groundlight_openapi_client/model/b_box_geometry_request.py groundlight_openapi_client/model/binary_classification_result.py @@ -140,6 +149,7 @@ groundlight_openapi_client/model/multi_classification_result.py groundlight_openapi_client/model/note.py groundlight_openapi_client/model/note_request.py groundlight_openapi_client/model/null_enum.py +groundlight_openapi_client/model/paginated_api_token_list.py groundlight_openapi_client/model/paginated_detector_list.py groundlight_openapi_client/model/paginated_image_query_list.py groundlight_openapi_client/model/paginated_ml_pipeline_list.py @@ -172,4 +182,9 @@ setup.cfg setup.py test-requirements.txt test/__init__.py +test/test_api_token.py +test/test_api_token_create_response.py +test/test_api_token_request.py +test/test_api_tokens_api.py +test/test_paginated_api_token_list.py tox.ini diff --git a/generated/README.md b/generated/README.md index 40630eba..9394c1fa 100644 --- a/generated/README.md +++ b/generated/README.md @@ -120,6 +120,10 @@ Class | Method | HTTP request | Description *ActionsApi* | [**get_rule**](docs/ActionsApi.md#get_rule) | **GET** /v1/actions/rules/{id} | *ActionsApi* | [**list_detector_rules**](docs/ActionsApi.md#list_detector_rules) | **GET** /v1/actions/detector/{detector_id}/rules | *ActionsApi* | [**list_rules**](docs/ActionsApi.md#list_rules) | **GET** /v1/actions/rules | +*ApiTokensApi* | [**create_api_token**](docs/ApiTokensApi.md#create_api_token) | **POST** /v1/api-tokens | +*ApiTokensApi* | [**delete_api_token**](docs/ApiTokensApi.md#delete_api_token) | **DELETE** /v1/api-tokens/{name} | +*ApiTokensApi* | [**get_api_token_by_snippet**](docs/ApiTokensApi.md#get_api_token_by_snippet) | **GET** /v1/api-tokens/by-snippet/{snippet} | +*ApiTokensApi* | [**list_api_tokens**](docs/ApiTokensApi.md#list_api_tokens) | **GET** /v1/api-tokens | *DetectorGroupsApi* | [**create_detector_group**](docs/DetectorGroupsApi.md#create_detector_group) | **POST** /v1/detector-groups | *DetectorGroupsApi* | [**get_detector_groups**](docs/DetectorGroupsApi.md#get_detector_groups) | **GET** /v1/detector-groups | *DetectorResetApi* | [**reset_detector**](docs/DetectorResetApi.md#reset_detector) | **DELETE** /v1/detector-reset/{id} | @@ -155,6 +159,9 @@ Class | Method | HTTP request | Description - [ActionList](docs/ActionList.md) - [AllNotes](docs/AllNotes.md) - [AnnotationsRequestedEnum](docs/AnnotationsRequestedEnum.md) + - [ApiToken](docs/ApiToken.md) + - [ApiTokenCreateResponse](docs/ApiTokenCreateResponse.md) + - [ApiTokenRequest](docs/ApiTokenRequest.md) - [BBoxGeometry](docs/BBoxGeometry.md) - [BBoxGeometryRequest](docs/BBoxGeometryRequest.md) - [BinaryClassificationResult](docs/BinaryClassificationResult.md) @@ -193,6 +200,7 @@ Class | Method | HTTP request | Description - [Note](docs/Note.md) - [NoteRequest](docs/NoteRequest.md) - [NullEnum](docs/NullEnum.md) + - [PaginatedApiTokenList](docs/PaginatedApiTokenList.md) - [PaginatedDetectorList](docs/PaginatedDetectorList.md) - [PaginatedImageQueryList](docs/PaginatedImageQueryList.md) - [PaginatedMLPipelineList](docs/PaginatedMLPipelineList.md) diff --git a/generated/docs/ApiToken.md b/generated/docs/ApiToken.md new file mode 100644 index 00000000..59d9e59d --- /dev/null +++ b/generated/docs/ApiToken.md @@ -0,0 +1,16 @@ +# ApiToken + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**raw_key_snippet** | **str** | Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. | [readonly] +**created_at** | **datetime** | When was this token created? | [readonly] +**last_used_at** | **datetime** | The most recent time this API token was used. (Helpful for detecting suspicious activity). | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokenCreateResponse.md b/generated/docs/ApiTokenCreateResponse.md new file mode 100644 index 00000000..61e92d66 --- /dev/null +++ b/generated/docs/ApiTokenCreateResponse.md @@ -0,0 +1,18 @@ +# ApiTokenCreateResponse + +Response shape for token creation. Adds the raw_key, which is only ever returned once, at creation time. + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**raw_key_snippet** | **str** | Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. | [readonly] +**created_at** | **datetime** | When was this token created? | [readonly] +**last_used_at** | **datetime** | The most recent time this API token was used. (Helpful for detecting suspicious activity). | [readonly] +**raw_key** | **str** | The full API token secret. Returned only once, when the token is created. | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokenRequest.md b/generated/docs/ApiTokenRequest.md new file mode 100644 index 00000000..98b5a380 --- /dev/null +++ b/generated/docs/ApiTokenRequest.md @@ -0,0 +1,13 @@ +# ApiTokenRequest + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokensApi.md b/generated/docs/ApiTokensApi.md new file mode 100644 index 00000000..cfab063f --- /dev/null +++ b/generated/docs/ApiTokensApi.md @@ -0,0 +1,325 @@ +# groundlight_openapi_client.ApiTokensApi + +All URIs are relative to *https://api.groundlight.ai/device-api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**create_api_token**](ApiTokensApi.md#create_api_token) | **POST** /v1/api-tokens | +[**delete_api_token**](ApiTokensApi.md#delete_api_token) | **DELETE** /v1/api-tokens/{name} | +[**get_api_token_by_snippet**](ApiTokensApi.md#get_api_token_by_snippet) | **GET** /v1/api-tokens/by-snippet/{snippet} | +[**list_api_tokens**](ApiTokensApi.md#list_api_tokens) | **GET** /v1/api-tokens | + + +# **create_api_token** +> ApiTokenCreateResponse create_api_token(api_token_request) + + + +Create a new API token, returning the raw_key exactly once in the response. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + api_token_request = ApiTokenRequest( + name="name_example", + expires_at=dateutil_parser('1970-01-01T00:00:00.00Z'), + ) # ApiTokenRequest | + + # example passing only required values which don't have defaults set + try: + api_response = api_instance.create_api_token(api_token_request) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->create_api_token: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **api_token_request** | [**ApiTokenRequest**](ApiTokenRequest.md)| | + +### Return type + +[**ApiTokenCreateResponse**](ApiTokenCreateResponse.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: application/json, application/x-www-form-urlencoded, multipart/form-data + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**201** | | - | + +[[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) + +# **delete_api_token** +> delete_api_token(name) + + + +Delete (revoke) an API token by name. The token must belong to the authenticated user. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + name = "name_example" # str | + + # example passing only required values which don't have defaults set + try: + api_instance.delete_api_token(name) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->delete_api_token: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **name** | **str**| | + +### Return type + +void (empty response body) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**204** | No response body | - | + +[[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) + +# **get_api_token_by_snippet** +> ApiToken get_api_token_by_snippet(snippet) + + + +Retrieve a single API token by its raw_key_snippet. Returns metadata only; the raw key is never retrievable after creation. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.api_token import ApiToken +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + snippet = "snippet_example" # str | + + # example passing only required values which don't have defaults set + try: + api_response = api_instance.get_api_token_by_snippet(snippet) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->get_api_token_by_snippet: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **snippet** | **str**| | + +### Return type + +[**ApiToken**](ApiToken.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | | - | + +[[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_api_tokens** +> PaginatedApiTokenList list_api_tokens() + + + +List all API tokens for the authenticated user. Returns metadata only; raw keys are never retrievable after creation. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + page = 1 # int | A page number within the paginated result set. (optional) + page_size = 1 # int | Number of results to return per page. (optional) + + # example passing only required values which don't have defaults set + # and optional values + try: + api_response = api_instance.list_api_tokens(page=page, page_size=page_size) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->list_api_tokens: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **page** | **int**| A page number within the paginated result set. | [optional] + **page_size** | **int**| Number of results to return per page. | [optional] + +### Return type + +[**PaginatedApiTokenList**](PaginatedApiTokenList.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | | - | + +[[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) + diff --git a/generated/docs/PaginatedApiTokenList.md b/generated/docs/PaginatedApiTokenList.md new file mode 100644 index 00000000..6b83c180 --- /dev/null +++ b/generated/docs/PaginatedApiTokenList.md @@ -0,0 +1,15 @@ +# PaginatedApiTokenList + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**count** | **int** | | +**results** | [**[ApiToken]**](ApiToken.md) | | +**next** | **str, none_type** | | [optional] +**previous** | **str, none_type** | | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/groundlight_openapi_client/api/api_tokens_api.py b/generated/groundlight_openapi_client/api/api_tokens_api.py new file mode 100644 index 00000000..38720ce2 --- /dev/null +++ b/generated/groundlight_openapi_client/api/api_tokens_api.py @@ -0,0 +1,431 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.api_client import ApiClient, Endpoint as _Endpoint +from groundlight_openapi_client.model_utils import ( # noqa: F401 + check_allowed_values, + check_validations, + date, + datetime, + file_type, + none_type, + validate_and_convert_types, +) +from groundlight_openapi_client.model.api_token import ApiToken +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList + + +class ApiTokensApi(object): + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None): + if api_client is None: + api_client = ApiClient() + self.api_client = api_client + self.create_api_token_endpoint = _Endpoint( + settings={ + "response_type": (ApiTokenCreateResponse,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens", + "operation_id": "create_api_token", + "http_method": "POST", + "servers": None, + }, + params_map={ + "all": [ + "api_token_request", + ], + "required": [ + "api_token_request", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "api_token_request": (ApiTokenRequest,), + }, + "attribute_map": {}, + "location_map": { + "api_token_request": "body", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + }, + api_client=api_client, + ) + self.delete_api_token_endpoint = _Endpoint( + settings={ + "response_type": None, + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens/{name}", + "operation_id": "delete_api_token", + "http_method": "DELETE", + "servers": None, + }, + params_map={ + "all": [ + "name", + ], + "required": [ + "name", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "name": (str,), + }, + "attribute_map": { + "name": "name", + }, + "location_map": { + "name": "path", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": [], + "content_type": [], + }, + api_client=api_client, + ) + self.get_api_token_by_snippet_endpoint = _Endpoint( + settings={ + "response_type": (ApiToken,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens/by-snippet/{snippet}", + "operation_id": "get_api_token_by_snippet", + "http_method": "GET", + "servers": None, + }, + params_map={ + "all": [ + "snippet", + ], + "required": [ + "snippet", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "snippet": (str,), + }, + "attribute_map": { + "snippet": "snippet", + }, + "location_map": { + "snippet": "path", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": [], + }, + api_client=api_client, + ) + self.list_api_tokens_endpoint = _Endpoint( + settings={ + "response_type": (PaginatedApiTokenList,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens", + "operation_id": "list_api_tokens", + "http_method": "GET", + "servers": None, + }, + params_map={ + "all": [ + "page", + "page_size", + ], + "required": [], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "page": (int,), + "page_size": (int,), + }, + "attribute_map": { + "page": "page", + "page_size": "page_size", + }, + "location_map": { + "page": "query", + "page_size": "query", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": [], + }, + api_client=api_client, + ) + + def create_api_token(self, api_token_request, **kwargs): + """create_api_token # noqa: E501 + + Create a new API token, returning the raw_key exactly once in the response. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.create_api_token(api_token_request, async_req=True) + >>> result = thread.get() + + Args: + api_token_request (ApiTokenRequest): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + ApiTokenCreateResponse + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["api_token_request"] = api_token_request + return self.create_api_token_endpoint.call_with_http_info(**kwargs) + + def delete_api_token(self, name, **kwargs): + """delete_api_token # noqa: E501 + + Delete (revoke) an API token by name. The token must belong to the authenticated user. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.delete_api_token(name, async_req=True) + >>> result = thread.get() + + Args: + name (str): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + None + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["name"] = name + return self.delete_api_token_endpoint.call_with_http_info(**kwargs) + + def get_api_token_by_snippet(self, snippet, **kwargs): + """get_api_token_by_snippet # noqa: E501 + + Retrieve a single API token by its raw_key_snippet. Returns metadata only; the raw key is never retrievable after creation. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.get_api_token_by_snippet(snippet, async_req=True) + >>> result = thread.get() + + Args: + snippet (str): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + ApiToken + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["snippet"] = snippet + return self.get_api_token_by_snippet_endpoint.call_with_http_info(**kwargs) + + def list_api_tokens(self, **kwargs): + """list_api_tokens # noqa: E501 + + List all API tokens for the authenticated user. Returns metadata only; raw keys are never retrievable after creation. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.list_api_tokens(async_req=True) + >>> result = thread.get() + + + Keyword Args: + page (int): A page number within the paginated result set.. [optional] + page_size (int): Number of results to return per page.. [optional] + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + PaginatedApiTokenList + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + return self.list_api_tokens_endpoint.call_with_http_info(**kwargs) diff --git a/generated/groundlight_openapi_client/apis/__init__.py b/generated/groundlight_openapi_client/apis/__init__.py index 4d24cda6..e19656e3 100644 --- a/generated/groundlight_openapi_client/apis/__init__.py +++ b/generated/groundlight_openapi_client/apis/__init__.py @@ -14,6 +14,7 @@ # Import APIs into API package: from groundlight_openapi_client.api.actions_api import ActionsApi +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi from groundlight_openapi_client.api.detector_groups_api import DetectorGroupsApi from groundlight_openapi_client.api.detector_reset_api import DetectorResetApi from groundlight_openapi_client.api.detectors_api import DetectorsApi diff --git a/generated/groundlight_openapi_client/model/api_token.py b/generated/groundlight_openapi_client/model/api_token.py new file mode 100644 index 00000000..a77ec253 --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token.py @@ -0,0 +1,300 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiToken(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "raw_key_snippet": (str,), # noqa: E501 + "created_at": (datetime,), # noqa: E501 + "last_used_at": (datetime,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "raw_key_snippet": "raw_key_snippet", # noqa: E501 + "created_at": "created_at", # noqa: E501 + "last_used_at": "last_used_at", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = { + "raw_key_snippet", # noqa: E501 + "created_at", # noqa: E501 + "last_used_at", # noqa: E501 + } + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, name, raw_key_snippet, created_at, last_used_at, *args, **kwargs): # noqa: E501 + """ApiToken - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + raw_key_snippet (str): Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. + created_at (datetime): When was this token created? + last_used_at (datetime): The most recent time this API token was used. (Helpful for detecting suspicious activity). + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + self.raw_key_snippet = raw_key_snippet + self.created_at = created_at + self.last_used_at = last_used_at + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiToken - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/api_token_create_response.py b/generated/groundlight_openapi_client/model/api_token_create_response.py new file mode 100644 index 00000000..1857d532 --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token_create_response.py @@ -0,0 +1,307 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiTokenCreateResponse(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "raw_key_snippet": (str,), # noqa: E501 + "created_at": (datetime,), # noqa: E501 + "last_used_at": (datetime,), # noqa: E501 + "raw_key": (str,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "raw_key_snippet": "raw_key_snippet", # noqa: E501 + "created_at": "created_at", # noqa: E501 + "last_used_at": "last_used_at", # noqa: E501 + "raw_key": "raw_key", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = { + "raw_key_snippet", # noqa: E501 + "created_at", # noqa: E501 + "last_used_at", # noqa: E501 + "raw_key", # noqa: E501 + } + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data( + cls, name, raw_key_snippet, created_at, last_used_at, raw_key, *args, **kwargs + ): # noqa: E501 + """ApiTokenCreateResponse - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + raw_key_snippet (str): Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. + created_at (datetime): When was this token created? + last_used_at (datetime): The most recent time this API token was used. (Helpful for detecting suspicious activity). + raw_key (str): The full API token secret. Returned only once, when the token is created. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + self.raw_key_snippet = raw_key_snippet + self.created_at = created_at + self.last_used_at = last_used_at + self.raw_key = raw_key + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiTokenCreateResponse - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/api_token_request.py b/generated/groundlight_openapi_client/model/api_token_request.py new file mode 100644 index 00000000..702cdcaa --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token_request.py @@ -0,0 +1,286 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiTokenRequest(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + "min_length": 1, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = {} + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, name, *args, **kwargs): # noqa: E501 + """ApiTokenRequest - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiTokenRequest - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/paginated_api_token_list.py b/generated/groundlight_openapi_client/model/paginated_api_token_list.py new file mode 100644 index 00000000..57f352dc --- /dev/null +++ b/generated/groundlight_openapi_client/model/paginated_api_token_list.py @@ -0,0 +1,302 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +def lazy_import(): + from groundlight_openapi_client.model.api_token import ApiToken + + globals()["ApiToken"] = ApiToken + + +class PaginatedApiTokenList(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = {} + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + lazy_import() + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + lazy_import() + return { + "count": (int,), # noqa: E501 + "results": ([ApiToken],), # noqa: E501 + "next": ( + str, + none_type, + ), # noqa: E501 + "previous": ( + str, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "count": "count", # noqa: E501 + "results": "results", # noqa: E501 + "next": "next", # noqa: E501 + "previous": "previous", # noqa: E501 + } + + read_only_vars = {} + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, count, results, *args, **kwargs): # noqa: E501 + """PaginatedApiTokenList - a model defined in OpenAPI + + Args: + count (int): + results ([ApiToken]): + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + next (str, none_type): [optional] # noqa: E501 + previous (str, none_type): [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.count = count + self.results = results + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, count, results, *args, **kwargs): # noqa: E501 + """PaginatedApiTokenList - a model defined in OpenAPI + + Args: + count (int): + results ([ApiToken]): + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + next (str, none_type): [optional] # noqa: E501 + previous (str, none_type): [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.count = count + self.results = results + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/models/__init__.py b/generated/groundlight_openapi_client/models/__init__.py index f35cb11e..94b00c6a 100644 --- a/generated/groundlight_openapi_client/models/__init__.py +++ b/generated/groundlight_openapi_client/models/__init__.py @@ -14,6 +14,9 @@ from groundlight_openapi_client.model.action_list import ActionList from groundlight_openapi_client.model.all_notes import AllNotes from groundlight_openapi_client.model.annotations_requested_enum import AnnotationsRequestedEnum +from groundlight_openapi_client.model.api_token import ApiToken +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest from groundlight_openapi_client.model.b_box_geometry import BBoxGeometry from groundlight_openapi_client.model.b_box_geometry_request import BBoxGeometryRequest from groundlight_openapi_client.model.binary_classification_result import BinaryClassificationResult @@ -52,6 +55,7 @@ from groundlight_openapi_client.model.note import Note from groundlight_openapi_client.model.note_request import NoteRequest from groundlight_openapi_client.model.null_enum import NullEnum +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList from groundlight_openapi_client.model.paginated_detector_list import PaginatedDetectorList from groundlight_openapi_client.model.paginated_image_query_list import PaginatedImageQueryList from groundlight_openapi_client.model.paginated_ml_pipeline_list import PaginatedMLPipelineList diff --git a/generated/model.py b/generated/model.py index a9c0f7f3..bb975546 100644 --- a/generated/model.py +++ b/generated/model.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: public-api.yaml -# timestamp: 2026-06-16T00:34:34+00:00 +# timestamp: 2026-07-09T18:23:13+00:00 from __future__ import annotations @@ -28,6 +28,53 @@ class AccountMonthToDateInfo(BaseModel): ) +class ApiToken(BaseModel): + name: constr(max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + raw_key_snippet: str = Field( + ..., description="Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token." + ) + created_at: datetime = Field(..., description="When was this token created?") + last_used_at: datetime = Field( + ..., description="The most recent time this API token was used. (Helpful for detecting suspicious activity)." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + + +class ApiTokenCreateResponse(BaseModel): + """ + Response shape for token creation. Adds the raw_key, which is only ever returned once, + at creation time. + """ + + name: constr(max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + raw_key_snippet: str = Field( + ..., description="Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token." + ) + created_at: datetime = Field(..., description="When was this token created?") + last_used_at: datetime = Field( + ..., description="The most recent time this API token was used. (Helpful for detecting suspicious activity)." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + raw_key: str = Field(..., description="The full API token secret. Returned only once, when the token is created.") + + +class ApiTokenRequest(BaseModel): + name: constr(min_length=1, max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + + class BBoxGeometry(BaseModel): """ Mixin for serializers to handle data in the StrictBaseModel format @@ -178,6 +225,13 @@ class NullEnum(Enum): NoneType_None = None +class PaginatedApiTokenList(BaseModel): + count: int = Field(..., examples=[123]) + next: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=4"]) + previous: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=2"]) + results: List[ApiToken] + + class PaginatedMLPipelineList(BaseModel): count: int = Field(..., examples=[123]) next: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=4"]) diff --git a/generated/test/test_api_token.py b/generated/test/test_api_token.py new file mode 100644 index 00000000..83ca5f3b --- /dev/null +++ b/generated/test/test_api_token.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token import ApiToken + + +class TestApiToken(unittest.TestCase): + """ApiToken unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiToken(self): + """Test ApiToken""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiToken() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_token_create_response.py b/generated/test/test_api_token_create_response.py new file mode 100644 index 00000000..a5153874 --- /dev/null +++ b/generated/test/test_api_token_create_response.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse + + +class TestApiTokenCreateResponse(unittest.TestCase): + """ApiTokenCreateResponse unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiTokenCreateResponse(self): + """Test ApiTokenCreateResponse""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiTokenCreateResponse() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_token_request.py b/generated/test/test_api_token_request.py new file mode 100644 index 00000000..3a150751 --- /dev/null +++ b/generated/test/test_api_token_request.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest + + +class TestApiTokenRequest(unittest.TestCase): + """ApiTokenRequest unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiTokenRequest(self): + """Test ApiTokenRequest""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiTokenRequest() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_tokens_api.py b/generated/test/test_api_tokens_api.py new file mode 100644 index 00000000..780b61f9 --- /dev/null +++ b/generated/test/test_api_tokens_api.py @@ -0,0 +1,44 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi # noqa: E501 + + +class TestApiTokensApi(unittest.TestCase): + """ApiTokensApi unit test stubs""" + + def setUp(self): + self.api = ApiTokensApi() # noqa: E501 + + def tearDown(self): + pass + + def test_create_api_token(self): + """Test case for create_api_token""" + pass + + def test_delete_api_token(self): + """Test case for delete_api_token""" + pass + + def test_get_api_token_by_snippet(self): + """Test case for get_api_token_by_snippet""" + pass + + def test_list_api_tokens(self): + """Test case for list_api_tokens""" + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_paginated_api_token_list.py b/generated/test/test_paginated_api_token_list.py new file mode 100644 index 00000000..b5cbd754 --- /dev/null +++ b/generated/test/test_paginated_api_token_list.py @@ -0,0 +1,38 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token import ApiToken + +globals()["ApiToken"] = ApiToken +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList + + +class TestPaginatedApiTokenList(unittest.TestCase): + """PaginatedApiTokenList unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testPaginatedApiTokenList(self): + """Test PaginatedApiTokenList""" + # FIXME: construct object with mandatory attributes with example values + # model = PaginatedApiTokenList() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index a62070be..c455d7a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,10 @@ version = "0.30.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver certifi = ">=2023.7.22" +filelock = "^3.12.0" frozendict = "^2.3.2" pillow = ">=11.0.0" # TODO: We may want to mark pillow (and numpy) as extra (https://python-poetry.org/docs/master/pyproject#extras) +platformdirs = "^4.0.0" pydantic = "^2.0.0" python = ">=3.9,<4.0" python-dateutil = "^2.9.0" diff --git a/spec/public-api.yaml b/spec/public-api.yaml index 494c1f2f..932887ad 100644 --- a/spec/public-api.yaml +++ b/spec/public-api.yaml @@ -144,6 +144,102 @@ paths: responses: '204': description: No response body + /v1/api-tokens: + get: + operationId: List API tokens + description: List all API tokens for the authenticated user. Returns metadata + only; raw keys are never retrievable after creation. + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedApiTokenList' + description: '' + post: + operationId: Create API token + description: Create a new API token, returning the raw_key exactly once in the + response. + tags: + - api-tokens + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + required: true + security: + - ApiToken: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ApiTokenCreateResponse' + description: '' + /v1/api-tokens/{name}: + delete: + operationId: Delete API token + description: Delete (revoke) an API token by name. The token must belong to + the authenticated user. + parameters: + - in: path + name: name + schema: + type: string + required: true + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '204': + description: No response body + /v1/api-tokens/by-snippet/{snippet}: + get: + operationId: Get API token by snippet + description: Retrieve a single API token by its raw_key_snippet. Returns metadata + only; the raw key is never retrievable after creation. + parameters: + - in: path + name: snippet + schema: + type: string + required: true + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ApiToken' + description: '' /v1/detector-groups: get: operationId: Get Detector Groups @@ -1015,6 +1111,99 @@ components: required: - CUSTOMER - GL + ApiToken: + type: object + properties: + name: + type: string + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + raw_key_snippet: + type: string + readOnly: true + description: Since we're storing hashed keys, it can be useful to see the + raw prefix snippet of the token. + created_at: + type: string + format: date-time + readOnly: true + description: When was this token created? + last_used_at: + type: string + format: date-time + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + required: + - created_at + - last_used_at + - name + - raw_key_snippet + ApiTokenCreateResponse: + type: object + description: |- + Response shape for token creation. Adds the raw_key, which is only ever returned once, + at creation time. + properties: + name: + type: string + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + raw_key_snippet: + type: string + readOnly: true + description: Since we're storing hashed keys, it can be useful to see the + raw prefix snippet of the token. + created_at: + type: string + format: date-time + readOnly: true + description: When was this token created? + last_used_at: + type: string + format: date-time + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + raw_key: + type: string + readOnly: true + description: The full API token secret. Returned only once, when the token + is created. + required: + - created_at + - last_used_at + - name + - raw_key + - raw_key_snippet + ApiTokenRequest: + type: object + properties: + name: + type: string + minLength: 1 + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + required: + - name BBoxGeometry: type: object description: Mixin for serializers to handle data in the StrictBaseModel format @@ -1615,6 +1804,29 @@ components: NullEnum: enum: - null + PaginatedApiTokenList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ApiToken' PaginatedDetectorList: type: object required: diff --git a/src/groundlight/client.py b/src/groundlight/client.py index edcb8771..56e0c23d 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -8,6 +8,7 @@ from typing import Any, Callable, List, Optional, Tuple, Union from groundlight_openapi_client import Configuration +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi from groundlight_openapi_client.api.detector_groups_api import DetectorGroupsApi from groundlight_openapi_client.api.detectors_api import DetectorsApi from groundlight_openapi_client.api.image_queries_api import ImageQueriesApi @@ -52,6 +53,7 @@ sanitize_endpoint_url, ) from groundlight.optional_imports import Image, np +from groundlight.token_refresh import TokenRefresher logger = logging.getLogger("groundlight.sdk") @@ -200,6 +202,14 @@ def __init__( self.user_api = UserApi(self.api_client) self.labels_api = LabelsApi(self.api_client) self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) + self.api_tokens_api = ApiTokensApi(self.api_client) + + # The provided token is a bootstrap credential. Trade it for a short-lived working + # token and keep it rotating in the background. All API calls use the working token. + self._token_refresher = TokenRefresher(self.api_tokens_api, self.configuration, api_token) + self._token_refresher.bootstrap() + self._token_refresher.start() + self.logged_in_user = "(not-logged-in)" self._verify_connectivity() @@ -207,6 +217,16 @@ def __repr__(self) -> str: # Don't call the API here because that can get us stuck in a loop rendering exception strings return f"Logged in as {self.logged_in_user} to Groundlight at {self.endpoint}" + def close(self) -> None: + """Stop the background token-refresh thread. Safe to call multiple times.""" + self._token_refresher.close() + + def __enter__(self) -> "Groundlight": + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + def _verify_connectivity(self) -> None: """ Verify that the client can connect to the Groundlight service, and raise a helpful diff --git a/src/groundlight/token_refresh.py b/src/groundlight/token_refresh.py new file mode 100644 index 00000000..a79c44cd --- /dev/null +++ b/src/groundlight/token_refresh.py @@ -0,0 +1,363 @@ +"""Background auto-refresh of the API token used by the Groundlight client. + +The client is given a long-lived *bootstrap* token (from the ``api_token`` argument +or the ``GROUNDLIGHT_API_TOKEN`` env var). That token is used only to mint short-lived +working tokens; it is never used directly for API calls once a working token exists. + +Each distinct bootstrap token gets its own on-disk cache slot keyed by its snippet, so +multiple credentials on one machine rotate independently. A per-instance daemon thread +mints a fresh working token daily and cleans up the previous one after a grace window. +Coordination across processes sharing the slot is handled by an OS advisory file lock +plus atomic slot writes, so exactly one process mints per cycle. + +See the GL-1709 API Token Security Plan for the full design. +""" + +import json +import logging +import os +import secrets +import sys +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import platformdirs +from filelock import FileLock, Timeout +from groundlight_openapi_client.exceptions import NotFoundException, UnauthorizedException +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest + +logger = logging.getLogger("groundlight.sdk") + +# Token lifetimes and cadence. Defined as constants so they are easy to change later. +TOKEN_TTL_DAYS = 30 # working tokens are minted with this expiry +REFRESH_INTERVAL_DAYS = 1 # mint a fresh working token this often +CLEANUP_GRACE_FACTOR = 2 # delete the previous token after this many refresh intervals + +SNIPPET_LENGTH = 20 # first N chars of a raw key; also the per-token cache slot key +TOKEN_NAME_MAX_LENGTH = 64 # server-side cap on the token name column +RANDOM_SUFFIX_LENGTH = 6 # hex chars appended to a minted token's name +FALLBACK_TOKEN_NAME = "sdk-auto" # base name used when the current token can't be found + +MINT_REQUEST_TIMEOUT = 10.0 # seconds; bounds each token API call so a hang can't pin the lock +LOCK_ACQUIRE_TIMEOUT = 5.0 # seconds; a missed refresh cycle is harmless, so don't block forever + +_SECONDS_PER_DAY = 24 * 60 * 60 + + +class TokenRefreshError(Exception): + """Raised when the client cannot obtain a working token at startup.""" + + +def resolve_token_dir() -> Path: + """Return the directory where token cache slots are stored. + + Defaults to ``~/.groundlight/tokens`` (a platform-appropriate app-data dir on + Windows), overridable via the ``GROUNDLIGHT_TOKEN_DIR`` env var. + """ + override = os.environ.get("GROUNDLIGHT_TOKEN_DIR") + if override: + return Path(override).expanduser() + if sys.platform.startswith("win"): + return Path(platformdirs.user_data_dir("groundlight")) / "tokens" + return Path.home() / ".groundlight" / "tokens" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _to_iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _from_iso(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _coerce_dt(value, default: datetime) -> datetime: + """Normalize an expires_at value (datetime or ISO string) to an aware UTC datetime.""" + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if isinstance(value, str) and value: + try: + return _from_iso(value) + except ValueError: + return default + return default + + +class TokenRefresher: # pylint: disable=too-many-instance-attributes + """Owns the working-token lifecycle for a single Groundlight client instance. + + Bootstraps a working token from the disk cache or by minting, keeps the client's + active token up to date on ``configuration``, and runs a daemon thread that rotates + the token on a daily cadence. + """ + + def __init__(self, tokens_api, configuration, bootstrap_token: str, token_dir: Optional[Path] = None): + self._tokens_api = tokens_api + self._configuration = configuration + self._bootstrap_token = bootstrap_token + self._snippet = bootstrap_token[:SNIPPET_LENGTH] + + self._token_dir = token_dir or resolve_token_dir() + self._slot_path = self._token_dir / f"{self._snippet}.json" + # The lock file is a stable sibling of the slot file. It is never renamed, so its + # inode is constant even as the slot file is atomically replaced -- this is what + # keeps the advisory lock meaningful across refreshes. + self._lock = FileLock(str(self._token_dir / f"{self._snippet}.lock")) + + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + # Disabled if the token endpoints aren't deployed yet; see bootstrap(). + self._enabled = True + + # -- lifecycle --------------------------------------------------------------- + + def bootstrap(self) -> None: + """Set the client's active token to a valid working token, minting one if needed. + + Uses a cached token when present and unexpired; otherwise mints a new one under + the file lock. Raises TokenRefreshError if the bootstrap token is rejected. + + TODO(GL-1618): The token endpoints (zuuul PR #6560) are not yet deployed + everywhere. Until they are, a 404 means "server side not live yet": we keep using + the bootstrap token directly and skip rotation, so the SDK keeps working during + the staged rollout. Remove this graceful-degradation path once the endpoints are + deployed to all environments and the SDK is enabled per the GL-1709 merge plan. + """ + self._ensure_token_dir() + + cached = self._load_current() + if cached and not self._is_expired(cached): + self._set_active_token(cached["raw_key"]) + return + + try: + with self._lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT): + # Re-check under the lock: another process may have minted while we waited. + cached = self._load_current() + if cached and not self._is_expired(cached): + self._set_active_token(cached["raw_key"]) + return + self._refresh(previous_current=cached) + except Timeout as exc: + # Someone else holds the lock, likely mid-mint. Re-read what they wrote. + cached = self._load_current() + if cached and not self._is_expired(cached): + self._set_active_token(cached["raw_key"]) + return + raise TokenRefreshError("Timed out acquiring the token lock and no valid cached token was found.") from exc + except NotFoundException: + self._enabled = False + self._set_active_token(self._bootstrap_token) + logger.info( + "API token endpoints are not available at this endpoint; skipping token auto-refresh " + "and using the provided token directly." + ) + except UnauthorizedException as e: + raise TokenRefreshError( + "The provided API token was rejected while minting a working token. It is probably invalid or revoked." + ) from e + except TokenRefreshError: + raise + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Bootstrap must surface any mint failure clearly rather than silently continuing. + raise TokenRefreshError(f"Failed to mint an initial API token: {e}") from e + + def start(self) -> None: + """Start the background daemon thread that rotates the working token, if enabled.""" + if not self._enabled: + return + self._thread = threading.Thread( + target=self._run, + name=f"gl-token-refresh-{self._snippet[:8]}", + daemon=True, + ) + self._thread.start() + + def close(self, timeout: float = 5.0) -> None: + """Signal the refresh thread to stop and wait briefly for it to exit.""" + self._stop_event.set() + thread = self._thread + if thread and thread.is_alive(): + thread.join(timeout=timeout) + + # -- background loop --------------------------------------------------------- + + def _run(self) -> None: + while not self._stop_event.is_set(): + current = self._load_current() + wait_seconds = self._seconds_until_refresh(current) + # Interruptible sleep: close() sets the event and wakes us immediately. + if self._stop_event.wait(timeout=wait_seconds): + return + try: + self._refresh_cycle() + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # A failed cycle must not kill the thread; the current token is still valid. + logger.warning(f"Token refresh cycle failed; will retry next cycle: {e}", exc_info=True) + + def _refresh_cycle(self) -> None: + """Acquire the lock, and mint + clean up only if no other process beat us to it.""" + try: + with self._lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT): + current = self._load_current() + if current and self._age_seconds(current) < REFRESH_INTERVAL_DAYS * _SECONDS_PER_DAY: + # Another process already refreshed within this interval; just adopt it. + self._set_active_token(current["raw_key"]) + return + self._cleanup_previous() + self._refresh(previous_current=current) + except Timeout: + logger.debug("Skipping token refresh cycle: could not acquire lock (another process is refreshing).") + + # -- minting and cleanup (all called while holding the lock) ------------------ + + def _refresh(self, previous_current: Optional[dict]) -> None: + """Mint a new working token, persist it as current, and demote the old one.""" + base_name = self._lookup_current_name() or FALLBACK_TOKEN_NAME + new_current = self._mint(self._generate_token_name(base_name)) + + previous = None + if previous_current: + previous = {"name": previous_current["name"], "minted_at": previous_current["minted_at"]} + self._write_slot(current=new_current, previous=previous) + self._set_active_token(new_current["raw_key"]) + + def _mint(self, name: str) -> dict: + """Create a new API token and return its slot representation. + + Mints with the currently active token. If that token was revoked server-side + (401), falls back once to the bootstrap token before giving up. + """ + expires_at = _utcnow() + timedelta(days=TOKEN_TTL_DAYS) + request = ApiTokenRequest(name=name, expires_at=expires_at) + try: + response = self._tokens_api.create_api_token(request, _request_timeout=MINT_REQUEST_TIMEOUT) + except UnauthorizedException: + # The active (cached) token is no longer valid. Retry with the bootstrap token. + logger.info("Active token rejected while minting; retrying with the bootstrap token.") + self._set_active_token(self._bootstrap_token) + response = self._tokens_api.create_api_token(request, _request_timeout=MINT_REQUEST_TIMEOUT) + + data = response.to_dict() + minted_at = _utcnow() + return { + "raw_key": data["raw_key"], + "snippet": data.get("raw_key_snippet", data["raw_key"][:SNIPPET_LENGTH]), + "name": data["name"], + "expires_at": _to_iso(_coerce_dt(data.get("expires_at"), minted_at + timedelta(days=TOKEN_TTL_DAYS))), + "minted_at": _to_iso(minted_at), + } + + def _cleanup_previous(self) -> None: + """Delete the previous token once it is safely past the grace window.""" + slot = self._read_slot() + previous = slot.get("previous") + if not previous: + return + age = (_utcnow() - _from_iso(previous["minted_at"])).total_seconds() + if age < CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS * _SECONDS_PER_DAY: + return + try: + self._tokens_api.delete_api_token(previous["name"], _request_timeout=MINT_REQUEST_TIMEOUT) + except NotFoundException: + pass # already gone; nothing to do + # Clear previous so we don't attempt the delete again next cycle. + self._write_slot(current=slot.get("current"), previous=None) + + def _lookup_current_name(self) -> Optional[str]: + """Find the active token's name by scanning the paginated token list. + + TODO(GL-1618): This paginated scan is temporary. Once + GET /v1/api-tokens/by-snippet/ is deployed (zuuul PR #6579), replace + this with _lookup_name_by_snippet(), which is a single direct lookup. Switch + before merging the server change. + """ + active_token = self._configuration.api_key["ApiToken"] + page = 1 + while True: + response = self._tokens_api.list_api_tokens(page=page, _request_timeout=MINT_REQUEST_TIMEOUT) + data = response.to_dict() + for token in data.get("results", []): + snippet = token.get("raw_key_snippet") + if snippet and active_token.startswith(snippet): + return token.get("name") + if not data.get("next"): + return None + page += 1 + + def _lookup_name_by_snippet(self) -> Optional[str]: + """Find the active token's name via the direct by-snippet endpoint. + + TODO(GL-1618): Not called yet. The GET /v1/api-tokens/by-snippet/ + endpoint (zuuul PR #6579) is not live. Once it is deployed, call this from + _refresh() in place of _lookup_current_name(), then delete the paginated scan. + """ + snippet = self._configuration.api_key["ApiToken"][:SNIPPET_LENGTH] + try: + response = self._tokens_api.get_api_token_by_snippet(snippet, _request_timeout=MINT_REQUEST_TIMEOUT) + except NotFoundException: + return None + return response.to_dict().get("name") + + def _generate_token_name(self, base_name: str) -> str: + """Append a random hex suffix to base_name, truncating to fit the name column.""" + suffix = secrets.token_hex(RANDOM_SUFFIX_LENGTH // 2) + max_base = TOKEN_NAME_MAX_LENGTH - len(suffix) - 1 # 1 for the separating space + return f"{base_name[:max_base]} {suffix}" + + # -- slot file I/O ----------------------------------------------------------- + + def _ensure_token_dir(self) -> None: + try: + self._token_dir.mkdir(parents=True, exist_ok=True) + os.chmod(self._token_dir, 0o700) + except OSError as e: + raise TokenRefreshError( + f"Cannot use token cache directory {self._token_dir}: {e}. " + "Set GROUNDLIGHT_TOKEN_DIR to a writable location." + ) from e + + def _read_slot(self) -> dict: + try: + with open(self._slot_path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def _load_current(self) -> Optional[dict]: + return self._read_slot().get("current") + + def _write_slot(self, current: Optional[dict], previous: Optional[dict]) -> None: + """Atomically write the slot file (temp file + rename) with 0600 permissions.""" + payload = json.dumps({"current": current, "previous": previous}, indent=2) + tmp_path = self._slot_path.with_suffix(f".{os.getpid()}.tmp") + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(payload) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + os.replace(tmp_path, self._slot_path) + + # -- helpers ----------------------------------------------------------------- + + def _set_active_token(self, raw_key: str) -> None: + self._configuration.api_key["ApiToken"] = raw_key + + def _is_expired(self, current: dict) -> bool: + return _utcnow() >= _from_iso(current["expires_at"]) + + def _age_seconds(self, current: dict) -> float: + return (_utcnow() - _from_iso(current["minted_at"])).total_seconds() + + def _seconds_until_refresh(self, current: Optional[dict]) -> float: + if not current: + return 0.0 + remaining = REFRESH_INTERVAL_DAYS * _SECONDS_PER_DAY - self._age_seconds(current) + return max(0.0, remaining) diff --git a/test/unit/test_token_refresh.py b/test/unit/test_token_refresh.py new file mode 100644 index 00000000..83951bb4 --- /dev/null +++ b/test/unit/test_token_refresh.py @@ -0,0 +1,349 @@ +"""Unit tests for the SDK token auto-refresh logic (groundlight.token_refresh). + +These tests exercise the refresher against a fake token API and a temp cache dir, so +they never touch the network or the real ~/.groundlight directory. +""" + +# pylint: disable=protected-access,too-few-public-methods,unused-argument + +import json +import stat +from datetime import timedelta + +import pytest +from groundlight.token_refresh import ( + CLEANUP_GRACE_FACTOR, + RANDOM_SUFFIX_LENGTH, + REFRESH_INTERVAL_DAYS, + TOKEN_NAME_MAX_LENGTH, + TokenRefresher, + TokenRefreshError, + _to_iso, + _utcnow, + resolve_token_dir, +) +from groundlight_openapi_client.exceptions import NotFoundException, UnauthorizedException + +BOOTSTRAP_TOKEN = "api_bootstrapXXXXXXXXXXXXtail" +OWNER_ONLY_MODE = 0o600 + + +class FakeResponse: + """Mimics a generated openapi model with a to_dict() method.""" + + def __init__(self, data: dict): + self._data = data + + def to_dict(self) -> dict: + return self._data + + +class FakeConfiguration: + def __init__(self, token: str): + self.api_key = {"ApiToken": token} + + +class FakeTokensApi: + """Minimal stand-in for the generated ApiTokensApi.""" + + def __init__(self, list_pages=None): + self.created = [] + self.deleted = [] + self._mint_counter = 0 + # list_pages: list of (results, next) tuples returned by successive list calls + self._list_pages = list_pages or [([], None)] + self.unauthorized_until_bootstrap = False + self.last_auth_token = None + + def create_api_token(self, api_token_request, **kwargs): + if self.unauthorized_until_bootstrap and self.last_auth_token != BOOTSTRAP_TOKEN: + raise UnauthorizedException(status=401) + self._mint_counter += 1 + raw_key = f"api_minted{self._mint_counter:04d}abcdefghij" + record = { + "raw_key": raw_key, + "raw_key_snippet": raw_key[:20], + "name": api_token_request.name, + "expires_at": _to_iso(_utcnow() + timedelta(days=30)), + } + self.created.append(record) + return FakeResponse(record) + + def delete_api_token(self, name, **kwargs): + self.deleted.append(name) + + def list_api_tokens(self, page=1, **kwargs): + results, next_url = self._list_pages[min(page - 1, len(self._list_pages) - 1)] + return FakeResponse({"count": len(results), "next": next_url, "results": results}) + + def get_api_token_by_snippet(self, snippet, **kwargs): + raise NotFoundException(status=404) + + +def _make_refresher(tmp_path, tokens_api=None, config=None) -> TokenRefresher: + tokens_api = tokens_api or FakeTokensApi() + config = config or FakeConfiguration(BOOTSTRAP_TOKEN) + return TokenRefresher(tokens_api, config, BOOTSTRAP_TOKEN, token_dir=tmp_path) + + +def test_resolve_token_dir_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("GROUNDLIGHT_TOKEN_DIR", str(tmp_path / "custom")) + assert resolve_token_dir() == tmp_path / "custom" + + +def test_generate_token_name_appends_suffix(tmp_path): + refresher = _make_refresher(tmp_path) + name = refresher._generate_token_name("My Token") + assert name.startswith("My Token ") + base, suffix = name.rsplit(" ", 1) + assert base == "My Token" + assert len(suffix) == RANDOM_SUFFIX_LENGTH + assert all(c in "0123456789abcdef" for c in suffix) + + +def test_generate_token_name_truncates_long_base(tmp_path): + refresher = _make_refresher(tmp_path) + name = refresher._generate_token_name("z" * 200) + assert len(name) <= TOKEN_NAME_MAX_LENGTH + + +def test_bootstrap_mints_and_persists_when_no_cache(tmp_path): + tokens_api = FakeTokensApi() + config = FakeConfiguration(BOOTSTRAP_TOKEN) + refresher = _make_refresher(tmp_path, tokens_api, config) + + refresher.bootstrap() + + assert len(tokens_api.created) == 1 + minted = tokens_api.created[0] + # The client's active token is now the minted working token, not the bootstrap token. + assert config.api_key["ApiToken"] == minted["raw_key"] + + slot = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + assert slot["current"]["raw_key"] == minted["raw_key"] + assert slot["previous"] is None + + +def test_bootstrap_uses_valid_cache_without_minting(tmp_path): + tokens_api = FakeTokensApi() + config = FakeConfiguration(BOOTSTRAP_TOKEN) + refresher = _make_refresher(tmp_path, tokens_api, config) + refresher._ensure_token_dir() + refresher._write_slot( + current={ + "raw_key": "api_cachedtokenvalue0000", + "snippet": "api_cachedtokenvalue", + "name": "cached", + "expires_at": _to_iso(_utcnow() + timedelta(days=10)), + "minted_at": _to_iso(_utcnow()), + }, + previous=None, + ) + + refresher.bootstrap() + + assert not tokens_api.created + assert config.api_key["ApiToken"] == "api_cachedtokenvalue0000" + + +def test_bootstrap_remints_when_cache_expired(tmp_path): + tokens_api = FakeTokensApi() + config = FakeConfiguration(BOOTSTRAP_TOKEN) + refresher = _make_refresher(tmp_path, tokens_api, config) + refresher._ensure_token_dir() + refresher._write_slot( + current={ + "raw_key": "api_expiredtoken00000000", + "snippet": "api_expiredtoken0000", + "name": "expired", + "expires_at": _to_iso(_utcnow() - timedelta(days=1)), + "minted_at": _to_iso(_utcnow() - timedelta(days=31)), + }, + previous=None, + ) + + refresher.bootstrap() + + assert len(tokens_api.created) == 1 + assert config.api_key["ApiToken"] == tokens_api.created[0]["raw_key"] + + +def test_bootstrap_degrades_when_endpoint_missing(tmp_path): + class MissingEndpointApi(FakeTokensApi): + def list_api_tokens(self, page=1, **kwargs): + raise NotFoundException(status=404) + + def create_api_token(self, api_token_request, **kwargs): + raise NotFoundException(status=404) + + config = FakeConfiguration(BOOTSTRAP_TOKEN) + refresher = _make_refresher(tmp_path, MissingEndpointApi(), config) + + refresher.bootstrap() + + # Falls back to the bootstrap token and disables rotation; no thread is started. + assert config.api_key["ApiToken"] == BOOTSTRAP_TOKEN + assert refresher._enabled is False + refresher.start() + assert refresher._thread is None + + +def test_bootstrap_raises_on_unauthorized(tmp_path): + class UnauthorizedApi(FakeTokensApi): + def list_api_tokens(self, page=1, **kwargs): + raise UnauthorizedException(status=401) + + def create_api_token(self, api_token_request, **kwargs): + raise UnauthorizedException(status=401) + + refresher = _make_refresher(tmp_path, UnauthorizedApi()) + with pytest.raises(TokenRefreshError): + refresher.bootstrap() + + +def test_bootstrap_raises_on_mint_failure(tmp_path): + class BrokenTokensApi(FakeTokensApi): + def create_api_token(self, api_token_request, **kwargs): + raise RuntimeError("network down") + + refresher = _make_refresher(tmp_path, BrokenTokensApi()) + with pytest.raises(TokenRefreshError): + refresher.bootstrap() + + +def test_lookup_current_name_matches_snippet_across_pages(tmp_path): + active = "api_minted0001abcdefghij" + pages = [ + ([{"raw_key_snippet": "api_someoneelse00000", "name": "other"}], "http://next"), + ([{"raw_key_snippet": active[:20], "name": "mine"}], None), + ] + tokens_api = FakeTokensApi(list_pages=pages) + config = FakeConfiguration(active) + refresher = _make_refresher(tmp_path, tokens_api, config) + + assert refresher._lookup_current_name() == "mine" + + +def test_lookup_current_name_returns_none_when_absent(tmp_path): + pages = [([{"raw_key_snippet": "api_nomatchhere00000", "name": "other"}], None)] + tokens_api = FakeTokensApi(list_pages=pages) + config = FakeConfiguration("api_minted0001abcdefghij") + refresher = _make_refresher(tmp_path, tokens_api, config) + + assert refresher._lookup_current_name() is None + + +def test_refresh_demotes_current_to_previous(tmp_path): + active = "api_minted0001abcdefghij" + pages = [([{"raw_key_snippet": active[:20], "name": "My Token"}], None)] + tokens_api = FakeTokensApi(list_pages=pages) + config = FakeConfiguration(active) + refresher = _make_refresher(tmp_path, tokens_api, config) + refresher._ensure_token_dir() + + old_current = { + "raw_key": active, + "snippet": active[:20], + "name": "My Token", + "expires_at": _to_iso(_utcnow() + timedelta(days=29)), + "minted_at": _to_iso(_utcnow()), + } + refresher._refresh(previous_current=old_current) + + slot = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + assert slot["current"]["name"].startswith("My Token ") + assert slot["previous"]["name"] == "My Token" + assert config.api_key["ApiToken"] == slot["current"]["raw_key"] + + +def test_cleanup_previous_deletes_after_grace(tmp_path): + refresher = _make_refresher(tmp_path) + refresher._ensure_token_dir() + stale_minted = _utcnow() - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS + 1) + refresher._write_slot( + current={ + "raw_key": "cur", + "snippet": "cur", + "name": "cur", + "expires_at": _to_iso(_utcnow()), + "minted_at": _to_iso(_utcnow()), + }, + previous={"name": "old-token", "minted_at": _to_iso(stale_minted)}, + ) + + refresher._cleanup_previous() + + assert refresher._tokens_api.deleted == ["old-token"] + slot = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + assert slot["previous"] is None + + +def test_cleanup_previous_skips_within_grace(tmp_path): + refresher = _make_refresher(tmp_path) + refresher._ensure_token_dir() + refresher._write_slot( + current={ + "raw_key": "cur", + "snippet": "cur", + "name": "cur", + "expires_at": _to_iso(_utcnow()), + "minted_at": _to_iso(_utcnow()), + }, + previous={"name": "recent-token", "minted_at": _to_iso(_utcnow())}, + ) + + refresher._cleanup_previous() + + assert not refresher._tokens_api.deleted + + +def test_cleanup_previous_treats_missing_as_gone(tmp_path): + class DeleteNotFoundApi(FakeTokensApi): + def delete_api_token(self, name, **kwargs): + raise NotFoundException(status=404) + + refresher = _make_refresher(tmp_path, DeleteNotFoundApi()) + refresher._ensure_token_dir() + stale_minted = _utcnow() - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS + 1) + refresher._write_slot( + current={ + "raw_key": "cur", + "snippet": "cur", + "name": "cur", + "expires_at": _to_iso(_utcnow()), + "minted_at": _to_iso(_utcnow()), + }, + previous={"name": "gone", "minted_at": _to_iso(stale_minted)}, + ) + + refresher._cleanup_previous() # should not raise + + slot = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + assert slot["previous"] is None + + +def test_mint_falls_back_to_bootstrap_on_unauthorized(tmp_path): + tokens_api = FakeTokensApi() + tokens_api.unauthorized_until_bootstrap = True + config = FakeConfiguration("api_staletoken0000000000") + + def track_token(raw_key): + config.api_key["ApiToken"] = raw_key + tokens_api.last_auth_token = raw_key + + refresher = _make_refresher(tmp_path, tokens_api, config) + refresher._set_active_token = track_token # capture which token authenticates the mint + tokens_api.last_auth_token = config.api_key["ApiToken"] + + result = refresher._mint("some-name") + + assert result["raw_key"] == tokens_api.created[0]["raw_key"] + + +def test_slot_file_written_with_owner_only_permissions(tmp_path): + refresher = _make_refresher(tmp_path) + refresher._ensure_token_dir() + refresher._write_slot(current={"raw_key": "x"}, previous=None) + + mode = stat.S_IMODE((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").stat().st_mode) + assert mode == OWNER_ONLY_MODE From fa1a97b4cf5dba04dc1f60c191b030556bbeaaa1 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 12:45:35 -0700 Subject: [PATCH 2/2] fix: exclude close() from CLI; fall back to bootstrap around refresh - Exclude the new Groundlight.close() lifecycle method from CLI command registration so `groundlight` and its tests don't KeyError on it. - Move the revoked-token bootstrap fallback to wrap both the name lookup and the mint, since the paginated-list lookup is the first call that would 401. Co-authored-by: Cursor --- src/groundlight/cli.py | 1 + src/groundlight/token_refresh.py | 34 ++++++++++++++++++-------------- test/unit/test_token_refresh.py | 9 ++++++--- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 82fa5fa9..0e265402 100644 --- a/src/groundlight/cli.py +++ b/src/groundlight/cli.py @@ -168,6 +168,7 @@ def wrapper(*args, **kwargs): # Methods that should not be exposed as CLI commands. Add a method here if its signature # cannot be cleanly represented as CLI arguments or if it is not useful as a shell command. _CLI_EXCLUDED_METHODS = { + "close", # lifecycle method (stops the token-refresh thread); not a shell command "create_roi", # returns an ROI object that must be passed to another API call; not useful standalone "get_raw_headers", # returns the API token in plaintext "make_generic_api_request", diff --git a/src/groundlight/token_refresh.py b/src/groundlight/token_refresh.py index a79c44cd..93f11a87 100644 --- a/src/groundlight/token_refresh.py +++ b/src/groundlight/token_refresh.py @@ -217,9 +217,18 @@ def _refresh_cycle(self) -> None: # -- minting and cleanup (all called while holding the lock) ------------------ def _refresh(self, previous_current: Optional[dict]) -> None: - """Mint a new working token, persist it as current, and demote the old one.""" - base_name = self._lookup_current_name() or FALLBACK_TOKEN_NAME - new_current = self._mint(self._generate_token_name(base_name)) + """Mint a new working token, persist it as current, and demote the old one. + + Both the name lookup and the mint use the currently active token. If that token + was revoked server-side (401 on either call), fall back once to the bootstrap + token and retry the whole sequence before giving up. + """ + try: + new_current = self._lookup_and_mint() + except UnauthorizedException: + logger.info("Active token rejected during refresh; retrying with the bootstrap token.") + self._set_active_token(self._bootstrap_token) + new_current = self._lookup_and_mint() previous = None if previous_current: @@ -227,21 +236,16 @@ def _refresh(self, previous_current: Optional[dict]) -> None: self._write_slot(current=new_current, previous=previous) self._set_active_token(new_current["raw_key"]) - def _mint(self, name: str) -> dict: - """Create a new API token and return its slot representation. + def _lookup_and_mint(self) -> dict: + """Find the active token's name (for a readable new name) and mint its replacement.""" + base_name = self._lookup_current_name() or FALLBACK_TOKEN_NAME + return self._mint(self._generate_token_name(base_name)) - Mints with the currently active token. If that token was revoked server-side - (401), falls back once to the bootstrap token before giving up. - """ + def _mint(self, name: str) -> dict: + """Create a new API token and return its slot representation.""" expires_at = _utcnow() + timedelta(days=TOKEN_TTL_DAYS) request = ApiTokenRequest(name=name, expires_at=expires_at) - try: - response = self._tokens_api.create_api_token(request, _request_timeout=MINT_REQUEST_TIMEOUT) - except UnauthorizedException: - # The active (cached) token is no longer valid. Retry with the bootstrap token. - logger.info("Active token rejected while minting; retrying with the bootstrap token.") - self._set_active_token(self._bootstrap_token) - response = self._tokens_api.create_api_token(request, _request_timeout=MINT_REQUEST_TIMEOUT) + response = self._tokens_api.create_api_token(request, _request_timeout=MINT_REQUEST_TIMEOUT) data = response.to_dict() minted_at = _utcnow() diff --git a/test/unit/test_token_refresh.py b/test/unit/test_token_refresh.py index 83951bb4..ac9e2730 100644 --- a/test/unit/test_token_refresh.py +++ b/test/unit/test_token_refresh.py @@ -322,7 +322,7 @@ def delete_api_token(self, name, **kwargs): assert slot["previous"] is None -def test_mint_falls_back_to_bootstrap_on_unauthorized(tmp_path): +def test_refresh_falls_back_to_bootstrap_on_unauthorized(tmp_path): tokens_api = FakeTokensApi() tokens_api.unauthorized_until_bootstrap = True config = FakeConfiguration("api_staletoken0000000000") @@ -334,10 +334,13 @@ def track_token(raw_key): refresher = _make_refresher(tmp_path, tokens_api, config) refresher._set_active_token = track_token # capture which token authenticates the mint tokens_api.last_auth_token = config.api_key["ApiToken"] + refresher._ensure_token_dir() - result = refresher._mint("some-name") + # The stale active token is rejected; refresh retries with the bootstrap token. + refresher._refresh(previous_current=None) - assert result["raw_key"] == tokens_api.created[0]["raw_key"] + assert len(tokens_api.created) == 1 + assert config.api_key["ApiToken"] == tokens_api.created[0]["raw_key"] def test_slot_file_written_with_owner_only_permissions(tmp_path):