From ed7f2660107625fb6f85caf5027e84d3ee82569f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 11:31:39 -0700 Subject: [PATCH 01/30] Add automatic API token refresh Rotate short-lived working tokens through a locked disk cache while preserving bootstrap credentials for recovery. Co-authored-by: Cursor --- generated/.openapi-generator/FILES | 10 + 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 | 25 + src/groundlight/internalapi.py | 17 +- src/groundlight/token_manager.py | 394 ++++++++++++++++ test/unit/test_token_manager.py | 180 ++++++++ test/unit/test_token_refresh_client.py | 40 ++ 27 files changed, 3146 insertions(+), 3 deletions(-) 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_manager.py create mode 100644 test/unit/test_token_manager.py create mode 100644 test/unit/test_token_refresh_client.py diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index 27394696..9a3e954b 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 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..7c8f5bba 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:25:20+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..050a4ddb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ requests = "^2.28.2" typer = "^0.15.4" urllib3 = "^2.6.1" pyyaml = "^6.0.3" +filelock = ">=3.19.1,<3.20" +platformdirs = ">=4.4,<4.5" [tool.poetry.group.dev.dependencies] datamodel-code-generator = "^0.35.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..7828234b 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -52,6 +52,7 @@ sanitize_endpoint_url, ) from groundlight.optional_imports import Image, np +from groundlight.token_manager import TokenManager, TokenManagerError logger = logging.getLogger("groundlight.sdk") @@ -193,6 +194,15 @@ def __init__( self.configuration.api_key["ApiToken"] = api_token self.api_client = GroundlightApiClient(self.configuration) + try: + self._token_manager = TokenManager( + bootstrap_token=api_token, + configuration=self.configuration, + request_timeout=DEFAULT_REQUEST_TIMEOUT, + ) + except TokenManagerError as exc: + raise ApiTokenError(str(exc)) from exc + self.api_client.set_unauthorized_handler(self._token_manager.recover_from_unauthorized) self.detectors_api = DetectorsApi(self.api_client) self.detector_group_api = DetectorGroupsApi(self.api_client) self.images_api = ImageQueriesApi(self.api_client) @@ -202,11 +212,26 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() + self._token_manager.start() def __repr__(self) -> str: + """Return a concise description of the connected client.""" # 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 __enter__(self) -> "Groundlight": + """Return this client for use as a context manager.""" + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Stop background work when leaving a context manager.""" + self.close() + + def close(self) -> None: + """Stop the token refresh thread and close the HTTP client.""" + self._token_manager.close() + self.api_client.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/internalapi.py b/src/groundlight/internalapi.py index fd4304cb..cbb722b8 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -6,6 +6,7 @@ import uuid from enum import Enum from functools import wraps +from http import HTTPStatus from typing import Callable, Optional from urllib.parse import urlsplit, urlunsplit @@ -164,14 +165,20 @@ class GroundlightApiClient(ApiClient): """ def __init__(self, *args, **kwargs): + """Initialize the generated API client with SDK-specific behavior.""" super().__init__(*args, **kwargs) self.user_agent = f"Groundlight-Python-SDK/{get_version()}/{platform.platform()}/{platform.python_version()}" + self._unauthorized_handler: Optional[Callable[[], None]] = None REQUEST_ID_HEADER = "X-Request-Id" + def set_unauthorized_handler(self, handler: Callable[[], None]) -> None: + """Set the callback used to recover and retry after a 401 response.""" + self._unauthorized_handler = handler + @RequestsRetryDecorator() def call_api(self, *args, **kwargs): - """Adds a request-id header to each API call.""" + """Add a request ID and retry once after token recovery from a 401.""" # Note we don't look for header_param in kwargs here, because this method is only called in one place # in the generated code, so we can afford to make this brittle. header_param = args[4] # that's the number in the list @@ -181,7 +188,13 @@ def call_api(self, *args, **kwargs): elif not header_param.get(self.REQUEST_ID_HEADER, None): header_param[self.REQUEST_ID_HEADER] = _generate_request_id() # Note that we have updated the actual dict in args, so we don't have to put it back in - return super().call_api(*args, **kwargs) + try: + return super().call_api(*args, **kwargs) + except ApiException as exc: + if exc.status != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: + raise + self._unauthorized_handler() + return super().call_api(*args, **kwargs) # # The methods below will eventually go away when we move to properly model diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py new file mode 100644 index 00000000..fa5dda71 --- /dev/null +++ b/src/groundlight/token_manager.py @@ -0,0 +1,394 @@ +import json +import logging +import os +import re +import secrets +import tempfile +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from filelock import FileLock +from filelock import Timeout as FileLockTimeout +from groundlight_openapi_client import Configuration +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi +from groundlight_openapi_client.exceptions import ApiException, NotFoundException +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 platformdirs import user_data_path + +from groundlight.internalapi import GroundlightApiClient + +logger = logging.getLogger("groundlight.sdk") + +TOKEN_SNIPPET_LENGTH = 20 +TOKEN_TTL_DAYS = 30 +REFRESH_INTERVAL_DAYS = 1 +CLEANUP_GRACE_FACTOR = 2 +TOKEN_NAME_MAX_LENGTH = 64 +TOKEN_NAME_SUFFIX_LENGTH = 7 +TOKEN_PAGE_SIZE = 100 +LOCK_TIMEOUT_SECONDS = 5 + + +class TokenManagerError(RuntimeError): + """Raised when the SDK cannot initialize or refresh its working API token.""" + + +def _utc_now() -> datetime: + """Return the current time as a timezone-aware UTC datetime.""" + return datetime.now(timezone.utc) + + +def _parse_datetime(value: str) -> datetime: + """Parse an ISO 8601 timestamp and normalize it to UTC.""" + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _format_datetime(value: datetime) -> str: + """Format a datetime as an ISO 8601 UTC timestamp.""" + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_datetime(value: datetime) -> datetime: + """Normalize a datetime to a timezone-aware UTC value.""" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +@dataclass(frozen=True) +class CurrentToken: + """Store the working token data needed for authentication and rotation.""" + + raw_key: str + snippet: str + name: str + expires_at: datetime + minted_at: datetime + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CurrentToken": + """Build a current token from its on-disk representation.""" + return cls( + raw_key=data["raw_key"], + snippet=data["snippet"], + name=data["name"], + expires_at=_parse_datetime(data["expires_at"]), + minted_at=_parse_datetime(data["minted_at"]), + ) + + def to_dict(self) -> Dict[str, str]: + """Convert the current token to its JSON-compatible representation.""" + return { + "raw_key": self.raw_key, + "snippet": self.snippet, + "name": self.name, + "expires_at": _format_datetime(self.expires_at), + "minted_at": _format_datetime(self.minted_at), + } + + +@dataclass(frozen=True) +class PreviousToken: + """Store the superseded token metadata needed for delayed cleanup.""" + + name: str + minted_at: datetime + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PreviousToken": + """Build previous-token metadata from its on-disk representation.""" + return cls(name=data["name"], minted_at=_parse_datetime(data["minted_at"])) + + def to_dict(self) -> Dict[str, str]: + """Convert previous-token metadata to its JSON-compatible representation.""" + return {"name": self.name, "minted_at": _format_datetime(self.minted_at)} + + +@dataclass(frozen=True) +class TokenSlot: + """Represent the current and previous tokens stored in one cache slot.""" + + current: CurrentToken + previous: Optional[PreviousToken] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TokenSlot": + """Build a token slot from its on-disk representation.""" + previous_data = data.get("previous") + return cls( + current=CurrentToken.from_dict(data["current"]), + previous=PreviousToken.from_dict(previous_data) if previous_data else None, + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert the token slot to its JSON-compatible representation.""" + return { + "current": self.current.to_dict(), + "previous": self.previous.to_dict() if self.previous else None, + } + + +class TokenManager: # pylint: disable=too-many-instance-attributes + """Manage cached API tokens and coordinate their automatic rotation.""" + + def __init__( + self, + bootstrap_token: str, + configuration: Configuration, + request_timeout: float, + token_dir: Optional[Path] = None, + ): + """Initialize the cache slot and select or mint a working API token.""" + self._bootstrap_token = bootstrap_token + self._bootstrap_snippet = bootstrap_token[:TOKEN_SNIPPET_LENGTH] + if len(self._bootstrap_snippet) != TOKEN_SNIPPET_LENGTH or not re.fullmatch( + r"[A-Za-z0-9_]+", self._bootstrap_snippet + ): + raise TokenManagerError("The bootstrap API token has an invalid format") + self._configuration = configuration + self._request_timeout = request_timeout + self._token_dir = token_dir or self._default_token_dir() + self._slot_path = self._token_dir / f"{self._bootstrap_snippet}.json" + self._lock_path = self._token_dir / f"{self._bootstrap_snippet}.lock" + self._lock = FileLock(str(self._lock_path), timeout=LOCK_TIMEOUT_SECONDS, mode=0o600) + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + self._current: Optional[CurrentToken] = None + + self._ensure_token_dir() + self._rotation_client = GroundlightApiClient(configuration) + self._api_tokens = ApiTokensApi(self._rotation_client) + self._initialize_token() + + @staticmethod + def _default_token_dir() -> Path: + """Return the platform-appropriate token cache directory.""" + configured_dir = os.environ.get("GROUNDLIGHT_TOKEN_DIR") + if configured_dir: + return Path(configured_dir).expanduser() + if os.name == "nt": + return user_data_path("Groundlight") / "tokens" + return Path.home() / ".groundlight" / "tokens" + + def _ensure_token_dir(self) -> None: + """Create the token directory or raise a clear configuration error.""" + try: + self._token_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + except OSError as exc: + raise TokenManagerError(f"Cannot create Groundlight token directory '{self._token_dir}': {exc}") from exc + if not os.access(self._token_dir, os.W_OK): + raise TokenManagerError(f"Groundlight token directory '{self._token_dir}' is not writable") + + def _initialize_token(self) -> None: + """Load a valid cached token or mint one with the bootstrap token.""" + try: + with self._lock: + slot = self._load_slot() + if slot and slot.current.expires_at > _utc_now(): + self._activate(slot.current) + return + self._set_api_token(self._bootstrap_token) + self._mint_replacement(slot) + except FileLockTimeout as exc: + raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc + except TokenManagerError: + raise + except Exception as exc: + raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc + + def start(self) -> None: + """Start the background token refresh thread.""" + if self._thread is not None: + return + self._thread = threading.Thread( + target=self._run, + name=f"gl-token-refresh-{self._bootstrap_snippet[:8]}", + daemon=True, + ) + self._thread.start() + + def close(self) -> None: + """Stop background refresh work and close its API client.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=self._request_timeout + 1) + self._rotation_client.close() + + def recover_from_unauthorized(self) -> None: + """Reload a newer cached token or re-mint using the bootstrap token.""" + failed_token = self._configuration.api_key["ApiToken"] + try: + with self._lock: + slot = self._load_slot() + if slot and slot.current.raw_key != failed_token and slot.current.expires_at > _utc_now(): + self._activate(slot.current) + return + self._set_api_token(self._bootstrap_token) + self._mint_replacement(slot) + except FileLockTimeout as exc: + raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc + except TokenManagerError: + raise + except Exception as exc: + raise TokenManagerError( + "The cached token was rejected and the bootstrap token could not replace it" + ) from exc + + def refresh(self) -> None: + """Refresh the working token if no other process has already done so.""" + try: + with self._lock: + slot = self._load_slot() + if slot is None: + self._set_api_token(self._bootstrap_token) + self._mint_replacement(None) + return + + self._activate(slot.current) + if _utc_now() - slot.current.minted_at < timedelta(days=REFRESH_INTERVAL_DAYS): + return + self._cleanup_previous(slot.previous) + self._mint_replacement(slot) + except FileLockTimeout: + logger.warning("Skipping token refresh because the cache lock could not be acquired") + + def _run(self) -> None: + """Refresh tokens on schedule until the client is closed.""" + while not self._stop_event.is_set(): + current = self._current + if current is None: + wait_seconds = 0.0 + else: + refresh_at = current.minted_at + timedelta(days=REFRESH_INTERVAL_DAYS) + wait_seconds = max(0.0, (refresh_at - _utc_now()).total_seconds()) + if self._stop_event.wait(wait_seconds): + return + try: + self.refresh() + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) + + def _load_slot(self) -> Optional[TokenSlot]: + """Load the cache slot, returning None when it does not exist.""" + if not self._slot_path.exists(): + return None + try: + with self._slot_path.open(encoding="utf-8") as slot_file: + return TokenSlot.from_dict(json.load(slot_file)) + except (KeyError, TypeError, ValueError, OSError) as exc: + raise TokenManagerError(f"Cannot read Groundlight token cache '{self._slot_path}': {exc}") from exc + + def _write_slot(self, slot: TokenSlot) -> None: + """Atomically write a private token cache slot.""" + temporary_path: Optional[str] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=self._token_dir, + prefix=f".{self._slot_path.name}.", + delete=False, + ) as temporary_file: + temporary_path = temporary_file.name + json.dump(slot.to_dict(), temporary_file, indent=2) + temporary_file.write("\n") + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.chmod(temporary_path, 0o600) + os.replace(temporary_path, self._slot_path) + except OSError as exc: + raise TokenManagerError(f"Cannot write Groundlight token cache '{self._slot_path}': {exc}") from exc + finally: + if temporary_path and os.path.exists(temporary_path): + os.unlink(temporary_path) + + def _mint_replacement(self, slot: Optional[TokenSlot]) -> CurrentToken: + """Mint, persist, and activate a replacement for the supplied slot.""" + source_snippet = slot.current.snippet if slot else self._bootstrap_snippet + source_token = self._find_token_by_snippet(source_snippet) + new_name = self._new_token_name(source_token.name if source_token else None) + minted_at = _utc_now() + response = self._api_tokens.create_api_token( + ApiTokenRequest(name=new_name, expires_at=minted_at + timedelta(days=TOKEN_TTL_DAYS)), + _request_timeout=self._request_timeout, + ) + current = self._current_from_response(response, minted_at) + previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) if slot else None + self._write_slot(TokenSlot(current=current, previous=previous)) + self._activate(current) + return current + + def _find_token_by_snippet(self, snippet: str) -> Optional[ApiToken]: + """Find a token by iterating through all pages of token metadata.""" + # TODO: Before merging, replace this paginated lookup with _get_token_by_snippet once that endpoint is live. + page = 1 + while True: + response = self._api_tokens.list_api_tokens( + page=page, + page_size=TOKEN_PAGE_SIZE, + _request_timeout=self._request_timeout, + ) + for token in response.results: + if token.raw_key_snippet == snippet: + return token + if not response.next: + return None + page += 1 + + def _get_token_by_snippet(self, snippet: str) -> ApiToken: + """Retrieve token metadata through the dedicated snippet endpoint.""" + # TODO: Before merging, switch _find_token_by_snippet callers to this method once the endpoint is live. + return self._api_tokens.get_api_token_by_snippet(snippet, _request_timeout=self._request_timeout) + + def _cleanup_previous(self, previous: Optional[PreviousToken]) -> None: + """Delete a superseded token after its cleanup grace period.""" + if previous is None: + return + grace_period = timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS) + if _utc_now() - previous.minted_at < grace_period: + return + try: + self._api_tokens.delete_api_token(previous.name, _request_timeout=self._request_timeout) + except NotFoundException: + logger.debug("Previous API token '%s' was already deleted", previous.name) + except ApiException: + logger.warning("Unable to delete previous API token '%s'", previous.name, exc_info=True) + + @staticmethod + def _new_token_name(base_name: Optional[str]) -> str: + """Create a readable, unique name that fits the API length limit.""" + suffix = secrets.token_hex(3) + if not base_name: + return f"sdk-auto {suffix}" + max_base_length = TOKEN_NAME_MAX_LENGTH - TOKEN_NAME_SUFFIX_LENGTH + return f"{base_name[:max_base_length]} {suffix}" + + @staticmethod + def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: + """Convert a token creation response into cached current-token data.""" + if response.expires_at is None: + raise TokenManagerError("The server returned a minted API token without an expiration") + return CurrentToken( + raw_key=response.raw_key, + snippet=response.raw_key_snippet, + name=response.name, + expires_at=_normalize_datetime(response.expires_at), + minted_at=minted_at, + ) + + def _activate(self, token: CurrentToken) -> None: + """Use a cached token for subsequent SDK API calls.""" + self._current = token + self._set_api_token(token.raw_key) + + def _set_api_token(self, token: str) -> None: + """Update the shared OpenAPI configuration with an API token.""" + self._configuration.api_key["ApiToken"] = token diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py new file mode 100644 index 00000000..28aace3c --- /dev/null +++ b/test/unit/test_token_manager.py @@ -0,0 +1,180 @@ +# pylint: disable=protected-access +import json +import stat +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from groundlight import token_manager +from groundlight.token_manager import ( + REFRESH_INTERVAL_DAYS, + TOKEN_NAME_MAX_LENGTH, + TOKEN_TTL_DAYS, + TokenManager, + TokenManagerError, +) +from groundlight_openapi_client import Configuration + +BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" +NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) +TOKEN_CACHE_MODE = 0o600 +EXPECTED_PAGE_COUNT = 2 + + +def _metadata(name: str, raw_key: str) -> SimpleNamespace: + """Build the token metadata returned by list operations.""" + return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20]) + + +def _created_token(name: str, raw_key: str, now: datetime) -> SimpleNamespace: + """Build a token creation response.""" + return SimpleNamespace( + name=name, + raw_key=raw_key, + raw_key_snippet=raw_key[:20], + expires_at=now + timedelta(days=TOKEN_TTL_DAYS), + ) + + +def _page(results, next_url=None) -> SimpleNamespace: + """Build one page of token metadata.""" + return SimpleNamespace(results=results, next=next_url) + + +def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: + """Create a token manager with deterministic API and time dependencies.""" + mocker.patch.object(token_manager, "ApiTokensApi", return_value=api) + mocker.patch.object(token_manager, "_utc_now", return_value=now) + configuration = Configuration(host="https://example.com/device-api") + configuration.api_key["ApiToken"] = BOOTSTRAP_TOKEN + return TokenManager( + bootstrap_token=BOOTSTRAP_TOKEN, + configuration=configuration, + request_timeout=1, + token_dir=tmp_path, + ) + + +def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): + """A missing slot is minted from the bootstrap token and stored with mode 0600.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + + manager = _manager(mocker, tmp_path, api) + + request = api.create_api_token.call_args.args[0] + assert request.name.startswith("Device token ") + assert request.expires_at == NOW + timedelta(days=TOKEN_TTL_DAYS) + assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" + assert stat.S_IMODE(manager._slot_path.stat().st_mode) == TOKEN_CACHE_MODE + cached = json.loads(manager._slot_path.read_text()) + assert cached["current"]["raw_key"] == "api_working_token_one" + assert cached["previous"] is None + + +def test_initialization_reuses_valid_cached_token(mocker, tmp_path): + """A valid slot is reused without making token API calls.""" + first_api = Mock() + first_api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + first_api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + first = _manager(mocker, tmp_path, first_api) + + second_api = Mock() + mocker.patch.object(token_manager, "ApiTokensApi", return_value=second_api) + configuration = Configuration(host="https://example.com/device-api") + configuration.api_key["ApiToken"] = BOOTSTRAP_TOKEN + second = TokenManager(BOOTSTRAP_TOKEN, configuration, request_timeout=1, token_dir=tmp_path) + + second_api.list_api_tokens.assert_not_called() + second_api.create_api_token.assert_not_called() + assert second._configuration.api_key["ApiToken"] == first._configuration.api_key["ApiToken"] + + +def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): + """Token naming finds the matching snippet on later pages and stays within 64 characters.""" + api = Mock() + long_name = "x" * 64 + api.list_api_tokens.side_effect = [ + _page([_metadata("Other token", "api_other_token_value")], "https://example.com/page=2"), + _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), + ] + api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) + mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") + + _manager(mocker, tmp_path, api) + + assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT + request = api.create_api_token.call_args.args[0] + assert request.name == f"{'x' * 57} abc123" + assert len(request.name) == TOKEN_NAME_MAX_LENGTH + + +def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): + """Scheduled refresh revokes an old previous token and records the replaced current token.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + + old_slot = json.loads(manager._slot_path.read_text()) + old_slot["previous"] = { + "name": "older token", + "minted_at": (NOW - timedelta(days=2)).isoformat(), + } + manager._slot_path.write_text(json.dumps(old_slot)) + later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=later) + api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) + + manager.refresh() + + api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) + cached = json.loads(manager._slot_path.read_text()) + assert cached["current"]["raw_key"] == "api_working_token_two" + assert cached["previous"]["name"] == "Device token abc123" + + +def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): + """A 401 reloads a token another process already wrote instead of minting again.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + + slot = json.loads(manager._slot_path.read_text()) + slot["current"]["raw_key"] = "api_newer_process_token" + slot["current"]["snippet"] = "api_newer_process_to" + manager._slot_path.write_text(json.dumps(slot)) + + manager.recover_from_unauthorized() + + assert manager._configuration.api_key["ApiToken"] == "api_newer_process_token" + api.create_api_token.assert_not_called() + + +def test_unauthorized_recovery_mints_with_bootstrap_token(mocker, tmp_path): + """A rejected cached token is replaced using the unchanged bootstrap credential.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", NOW) + + manager.recover_from_unauthorized() + + api.create_api_token.assert_called_once() + assert manager._configuration.api_key["ApiToken"] == "api_working_token_two" + + +def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): + """Invalid token snippets are rejected before cache paths are created.""" + configuration = Configuration(host="https://example.com/device-api") + + with pytest.raises(TokenManagerError, match="invalid format"): + TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py new file mode 100644 index 00000000..e835be3f --- /dev/null +++ b/test/unit/test_token_refresh_client.py @@ -0,0 +1,40 @@ +from unittest.mock import Mock + +from groundlight.client import Groundlight +from groundlight.internalapi import GroundlightApiClient +from groundlight_openapi_client import Configuration +from groundlight_openapi_client.api_client import ApiClient, ApiException + +EXPECTED_CALL_COUNT = 2 + + +def test_groundlight_starts_and_closes_token_manager(mocker): + """Groundlight owns the token manager lifecycle and supports context management.""" + manager = Mock() + token_manager_class = mocker.patch("groundlight.client.TokenManager", return_value=manager) + mocker.patch.object(Groundlight, "_verify_connectivity") + client = Groundlight(api_token="api_bootstrap_token_value_long_enough") + api_client_close = mocker.patch.object(client.api_client, "close") + + with client as entered_client: + assert entered_client is client + token_manager_class.assert_called_once() + manager.start.assert_called_once() + + manager.close.assert_called_once() + api_client_close.assert_called_once() + + +def test_api_client_recovers_and_retries_once_after_unauthorized(mocker): + """The custom API client refreshes credentials and retries one failed request.""" + configuration = Configuration(host="https://example.com/device-api") + client = GroundlightApiClient(configuration) + handler = Mock() + client.set_unauthorized_handler(handler) + parent_call = mocker.patch.object(ApiClient, "call_api", side_effect=[ApiException(status=401), "success"]) + + result = client.call_api("/v1/test", "GET", {}, [], {}) + + assert result == "success" + handler.assert_called_once_with() + assert parent_call.call_count == EXPECTED_CALL_COUNT From ee0b1f36d12a13a55b6898737829e15053960873 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 11:59:18 -0700 Subject: [PATCH 02/30] Exclude client lifecycle from CLI commands Keep the new close method from being auto-registered as a shell command so CLI initialization and tests continue to work. Co-authored-by: Cursor --- src/groundlight/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 82fa5fa9..5a1a3b17 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; not useful as a standalone 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", From e5a575d45b34aa6fcfa6a178c56d989ea0aa1e1b Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 12:13:59 -0700 Subject: [PATCH 03/30] Harden token refresh failure handling Back off after failed refreshes, preserve pending cleanup metadata, and make 401 recovery safe for streamed and raw HTTP requests. Co-authored-by: Cursor --- src/groundlight/edge/api.py | 3 +- src/groundlight/internalapi.py | 43 ++++++++++++++++++-- src/groundlight/token_manager.py | 41 ++++++++++++------- test/unit/test_token_manager.py | 55 +++++++++++++++++++++++++- test/unit/test_token_refresh_client.py | 51 ++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 20 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index 3a918cee..b410ea13 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -31,10 +31,11 @@ def _base_url(self) -> str: return self._client.edge_base_url() def _request(self, method: str, path: str, **kwargs) -> requests.Response: + """Send an authenticated request to an Edge Endpoint.""" url = f"{self._base_url()}{path}" headers = self._client.get_raw_headers() try: - response = requests.request( + response = self._client.api_client.request_with_unauthorized_recovery( method, url, headers=headers, verify=self._client.configuration.verify_ssl, timeout=10, **kwargs ) response.raise_for_status() diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index cbb722b8..e98d525c 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -1,3 +1,4 @@ +import io import logging import os import platform @@ -7,7 +8,7 @@ from enum import Enum from functools import wraps from http import HTTPStatus -from typing import Callable, Optional +from typing import Callable, Optional, Tuple from urllib.parse import urlsplit, urlunsplit import requests @@ -18,6 +19,7 @@ from groundlight.version import get_version logger = logging.getLogger("groundlight.sdk") +REQUEST_BODY_ARG_INDEX = 5 class NotFoundError(Exception): @@ -176,9 +178,24 @@ def set_unauthorized_handler(self, handler: Callable[[], None]) -> None: """Set the callback used to recover and retry after a 401 response.""" self._unauthorized_handler = handler + @staticmethod + def _prepare_replayable_request_body(args: tuple) -> Tuple[tuple, Optional[bytes]]: + """Copy a stream body so each request attempt receives a fresh stream.""" + if len(args) <= REQUEST_BODY_ARG_INDEX or not isinstance(args[REQUEST_BODY_ARG_INDEX], io.IOBase): + return args, None + body = args[REQUEST_BODY_ARG_INDEX] + try: + body_bytes = body.read() + finally: + body.close() + replayable_args = list(args) + replayable_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(body_bytes) + return tuple(replayable_args), body_bytes + @RequestsRetryDecorator() def call_api(self, *args, **kwargs): """Add a request ID and retry once after token recovery from a 401.""" + args, replayable_body = self._prepare_replayable_request_body(args) # Note we don't look for header_param in kwargs here, because this method is only called in one place # in the generated code, so we can afford to make this brittle. header_param = args[4] # that's the number in the list @@ -194,7 +211,21 @@ def call_api(self, *args, **kwargs): if exc.status != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: raise self._unauthorized_handler() - return super().call_api(*args, **kwargs) + retry_args = list(args) + if replayable_body is not None: + retry_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(replayable_body) + return super().call_api(*retry_args, **kwargs) + + def request_with_unauthorized_recovery(self, method: str, url: str, **kwargs) -> requests.Response: + """Send a raw request and retry once with refreshed credentials after a 401.""" + response = requests.request(method, url, **kwargs) + if response.status_code != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: + return response + self._unauthorized_handler() + headers = dict(kwargs.get("headers", {})) + headers["x-api-token"] = self.configuration.api_key["ApiToken"] + kwargs["headers"] = headers + return requests.request(method, url, **kwargs) # # The methods below will eventually go away when we move to properly model @@ -226,7 +257,9 @@ def _add_label(self, image_query_id: str, label: str) -> dict: headers = self._headers() logger.info(f"Posting label={label} to image_query {image_query_id} ...") - response = requests.request("POST", url, json=data, headers=headers, verify=self.configuration.verify_ssl) + response = self.request_with_unauthorized_recovery( + "POST", url, json=data, headers=headers, verify=self.configuration.verify_ssl + ) elapsed = 1000 * (time.time() - start_time) logger.debug(f"Call to ImageQuery.add_label took {elapsed:.1f}ms response={response.text}") @@ -247,7 +280,9 @@ def _get_detector_by_name(self, name: str) -> Detector: """ url = f"{self.configuration.host}/v1/detectors?name={name}" headers = self._headers() - response = requests.request("GET", url, headers=headers, verify=self.configuration.verify_ssl) + response = self.request_with_unauthorized_recovery( + "GET", url, headers=headers, verify=self.configuration.verify_ssl + ) if not is_ok(response.status_code): raise InternalApiError(status=response.status_code, http_resp=response) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index fa5dda71..c9811211 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -32,6 +32,7 @@ TOKEN_NAME_SUFFIX_LENGTH = 7 TOKEN_PAGE_SIZE = 100 LOCK_TIMEOUT_SECONDS = 5 +REFRESH_INTERVAL_SECONDS = timedelta(days=REFRESH_INTERVAL_DAYS).total_seconds() class TokenManagerError(RuntimeError): @@ -196,7 +197,7 @@ def _initialize_token(self) -> None: self._activate(slot.current) return self._set_api_token(self._bootstrap_token) - self._mint_replacement(slot) + self._mint_replacement(slot, record_replaced_current=False) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc except TokenManagerError: @@ -232,7 +233,7 @@ def recover_from_unauthorized(self) -> None: self._activate(slot.current) return self._set_api_token(self._bootstrap_token) - self._mint_replacement(slot) + self._mint_replacement(slot, record_replaced_current=False) except FileLockTimeout as exc: raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc except TokenManagerError: @@ -242,23 +243,26 @@ def recover_from_unauthorized(self) -> None: "The cached token was rejected and the bootstrap token could not replace it" ) from exc - def refresh(self) -> None: - """Refresh the working token if no other process has already done so.""" + def refresh(self) -> bool: + """Refresh the working token, returning whether the cycle completed successfully.""" try: with self._lock: slot = self._load_slot() if slot is None: self._set_api_token(self._bootstrap_token) self._mint_replacement(None) - return + return True self._activate(slot.current) if _utc_now() - slot.current.minted_at < timedelta(days=REFRESH_INTERVAL_DAYS): - return - self._cleanup_previous(slot.previous) + return True + if not self._cleanup_previous(slot.previous): + return False self._mint_replacement(slot) + return True except FileLockTimeout: logger.warning("Skipping token refresh because the cache lock could not be acquired") + return False def _run(self) -> None: """Refresh tokens on schedule until the client is closed.""" @@ -272,9 +276,12 @@ def _run(self) -> None: if self._stop_event.wait(wait_seconds): return try: - self.refresh() + refresh_succeeded = self.refresh() except Exception: # pylint: disable=broad-exception-caught logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) + refresh_succeeded = False + if not refresh_succeeded and self._stop_event.wait(REFRESH_INTERVAL_SECONDS): + return def _load_slot(self) -> Optional[TokenSlot]: """Load the cache slot, returning None when it does not exist.""" @@ -310,7 +317,7 @@ def _write_slot(self, slot: TokenSlot) -> None: if temporary_path and os.path.exists(temporary_path): os.unlink(temporary_path) - def _mint_replacement(self, slot: Optional[TokenSlot]) -> CurrentToken: + def _mint_replacement(self, slot: Optional[TokenSlot], *, record_replaced_current: bool = True) -> CurrentToken: """Mint, persist, and activate a replacement for the supplied slot.""" source_snippet = slot.current.snippet if slot else self._bootstrap_snippet source_token = self._find_token_by_snippet(source_snippet) @@ -321,7 +328,11 @@ def _mint_replacement(self, slot: Optional[TokenSlot]) -> CurrentToken: _request_timeout=self._request_timeout, ) current = self._current_from_response(response, minted_at) - previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) if slot else None + previous: Optional[PreviousToken] + if slot and record_replaced_current: + previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) + else: + previous = slot.previous if slot else None self._write_slot(TokenSlot(current=current, previous=previous)) self._activate(current) return current @@ -348,19 +359,21 @@ def _get_token_by_snippet(self, snippet: str) -> ApiToken: # TODO: Before merging, switch _find_token_by_snippet callers to this method once the endpoint is live. return self._api_tokens.get_api_token_by_snippet(snippet, _request_timeout=self._request_timeout) - def _cleanup_previous(self, previous: Optional[PreviousToken]) -> None: - """Delete a superseded token after its cleanup grace period.""" + def _cleanup_previous(self, previous: Optional[PreviousToken]) -> bool: + """Delete due token metadata, returning whether it is safe to replace the slot.""" if previous is None: - return + return True grace_period = timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS) if _utc_now() - previous.minted_at < grace_period: - return + return False try: self._api_tokens.delete_api_token(previous.name, _request_timeout=self._request_timeout) except NotFoundException: logger.debug("Previous API token '%s' was already deleted", previous.name) except ApiException: logger.warning("Unable to delete previous API token '%s'", previous.name, exc_info=True) + return False + return True @staticmethod def _new_token_name(base_name: Optional[str]) -> str: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 28aace3c..ee164f9b 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -3,18 +3,20 @@ import stat from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, call import pytest from groundlight import token_manager from groundlight.token_manager import ( REFRESH_INTERVAL_DAYS, + REFRESH_INTERVAL_SECONDS, TOKEN_NAME_MAX_LENGTH, TOKEN_TTL_DAYS, TokenManager, TokenManagerError, ) from groundlight_openapi_client import Configuration +from groundlight_openapi_client.exceptions import ApiException BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) @@ -137,6 +139,49 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): assert cached["previous"]["name"] == "Device token abc123" +def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path): + """A failed cleanup postpones minting so the deletion can be retried later.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + slot = json.loads(manager._slot_path.read_text()) + slot["previous"] = { + "name": "older token", + "minted_at": (NOW - timedelta(days=2)).isoformat(), + } + manager._slot_path.write_text(json.dumps(slot)) + mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) + api.reset_mock() + api.delete_api_token.side_effect = ApiException(status=500) + + refresh_succeeded = manager.refresh() + + assert not refresh_succeeded + api.create_api_token.assert_not_called() + cached = json.loads(manager._slot_path.read_text()) + assert cached["previous"]["name"] == "older token" + assert cached["current"]["raw_key"] == "api_working_token_one" + + +def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): + """A failed refresh waits one interval instead of immediately retrying.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) + mocker.patch.object(manager, "refresh", return_value=False) + stop_event = Mock() + stop_event.is_set.return_value = False + stop_event.wait.side_effect = [False, True] + manager._stop_event = stop_event + + manager._run() + + assert stop_event.wait.call_args_list == [call(0.0), call(REFRESH_INTERVAL_SECONDS)] + + def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): """A 401 reloads a token another process already wrote instead of minting again.""" api = Mock() @@ -163,6 +208,12 @@ def test_unauthorized_recovery_mints_with_bootstrap_token(mocker, tmp_path): api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() + slot = json.loads(manager._slot_path.read_text()) + slot["previous"] = { + "name": "older token", + "minted_at": (NOW - timedelta(days=1)).isoformat(), + } + manager._slot_path.write_text(json.dumps(slot)) api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", NOW) @@ -170,6 +221,8 @@ def test_unauthorized_recovery_mints_with_bootstrap_token(mocker, tmp_path): api.create_api_token.assert_called_once() assert manager._configuration.api_key["ApiToken"] == "api_working_token_two" + cached = json.loads(manager._slot_path.read_text()) + assert cached["previous"]["name"] == "older token" def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index e835be3f..4cc2486d 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -1,3 +1,5 @@ +from http import HTTPStatus +from io import BytesIO from unittest.mock import Mock from groundlight.client import Groundlight @@ -38,3 +40,52 @@ def test_api_client_recovers_and_retries_once_after_unauthorized(mocker): assert result == "success" handler.assert_called_once_with() assert parent_call.call_count == EXPECTED_CALL_COUNT + + +def test_api_client_replays_stream_body_after_unauthorized(mocker): + """A 401 retry resends stream content instead of reusing a closed stream.""" + configuration = Configuration(host="https://example.com/device-api") + client = GroundlightApiClient(configuration) + client.set_unauthorized_handler(Mock()) + request_bodies = [] + + def call_parent(*args, **_kwargs): + """Consume each stream like the generated API client does.""" + request_bodies.append(args[5].read()) + args[5].close() + if len(request_bodies) == 1: + raise ApiException(status=401) + return "success" + + mocker.patch.object(ApiClient, "call_api", side_effect=call_parent) + body = BytesIO(b"image bytes") + + result = client.call_api("/v1/image-queries", "POST", {}, [], {}, body) + + assert result == "success" + assert body.closed + assert request_bodies == [b"image bytes", b"image bytes"] + + +def test_raw_request_recovers_and_retries_once_after_unauthorized(mocker): + """Raw authenticated requests refresh their token and retry once after a 401.""" + configuration = Configuration(host="https://example.com/device-api") + configuration.api_key["ApiToken"] = "old-token" + client = GroundlightApiClient(configuration) + + def refresh_token() -> None: + """Simulate replacing the rejected token.""" + configuration.api_key["ApiToken"] = "new-token" + + client.set_unauthorized_handler(refresh_token) + unauthorized = Mock(status_code=HTTPStatus.UNAUTHORIZED) + success = Mock(status_code=HTTPStatus.OK) + request = mocker.patch("groundlight.internalapi.requests.request", side_effect=[unauthorized, success]) + + response = client.request_with_unauthorized_recovery( + "GET", "https://example.com/device-api/v1/detectors", headers={"x-api-token": "old-token"} + ) + + assert response is success + assert request.call_count == EXPECTED_CALL_COUNT + assert request.call_args_list[1].kwargs["headers"]["x-api-token"] == "new-token" From 7c26f28c55da163f12ea81b821cc9c2d4dfbc41d Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 12:28:43 -0700 Subject: [PATCH 04/30] Keep clients usable before token API rollout Fall back to the bootstrap token when a server does not yet expose token management, preserving compatibility during deployment. Co-authored-by: Cursor --- src/groundlight/token_manager.py | 27 +++++++++++++++++++++++++-- test/unit/test_token_manager.py | 16 +++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index c9811211..65a19313 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -163,6 +163,7 @@ def __init__( self._stop_event = threading.Event() self._thread: Optional[threading.Thread] = None self._current: Optional[CurrentToken] = None + self._available = True self._ensure_token_dir() self._rotation_client = GroundlightApiClient(configuration) @@ -200,14 +201,20 @@ def _initialize_token(self) -> None: self._mint_replacement(slot, record_replaced_current=False) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc + except NotFoundException: + logger.warning( + "Automatic API token refresh is unavailable because this server does not support token management" + ) + self._available = False + self._set_api_token(self._bootstrap_token) except TokenManagerError: raise except Exception as exc: raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc def start(self) -> None: - """Start the background token refresh thread.""" - if self._thread is not None: + """Start background refresh when the server supports token management.""" + if not self._available or self._thread is not None: return self._thread = threading.Thread( target=self._run, @@ -225,6 +232,8 @@ def close(self) -> None: def recover_from_unauthorized(self) -> None: """Reload a newer cached token or re-mint using the bootstrap token.""" + if not self._available: + raise TokenManagerError("Automatic token recovery is unavailable on this server") failed_token = self._configuration.api_key["ApiToken"] try: with self._lock: @@ -234,6 +243,13 @@ def recover_from_unauthorized(self) -> None: return self._set_api_token(self._bootstrap_token) self._mint_replacement(slot, record_replaced_current=False) + except NotFoundException: + logger.warning( + "Automatic API token refresh is unavailable because this server does not support token management" + ) + self._available = False + self._stop_event.set() + self._set_api_token(self._bootstrap_token) except FileLockTimeout as exc: raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc except TokenManagerError: @@ -260,6 +276,13 @@ def refresh(self) -> bool: return False self._mint_replacement(slot) return True + except NotFoundException: + logger.warning( + "Automatic API token refresh is unavailable because this server does not support token management" + ) + self._available = False + self._stop_event.set() + return False except FileLockTimeout: logger.warning("Skipping token refresh because the cache lock could not be acquired") return False diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index ee164f9b..2091f969 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -16,7 +16,7 @@ TokenManagerError, ) from groundlight_openapi_client import Configuration -from groundlight_openapi_client.exceptions import ApiException +from groundlight_openapi_client.exceptions import ApiException, NotFoundException BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) @@ -94,6 +94,20 @@ def test_initialization_reuses_valid_cached_token(mocker, tmp_path): assert second._configuration.api_key["ApiToken"] == first._configuration.api_key["ApiToken"] +def test_initialization_uses_bootstrap_when_token_api_is_unavailable(mocker, tmp_path): + """A server without token management remains usable with the bootstrap token.""" + api = Mock() + api.list_api_tokens.side_effect = NotFoundException() + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == BOOTSTRAP_TOKEN + assert manager._thread is None + api.list_api_tokens.assert_called_once() + api.create_api_token.assert_not_called() + + def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): """Token naming finds the matching snippet on later pages and stays within 64 characters.""" api = Mock() From b41c4aefd538aa3f455563bde9f7dc7330bb68f1 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 12:45:28 -0700 Subject: [PATCH 05/30] Close remaining token refresh retry gaps Replay generated stream bodies correctly, cover note requests, and make locking and shutdown safe for in-flight refresh work. --- src/groundlight/experimental_api.py | 4 ++- src/groundlight/internalapi.py | 34 ++++++++++++++++++-------- src/groundlight/token_manager.py | 4 +-- test/unit/test_token_manager.py | 16 ++++++++++++ test/unit/test_token_refresh_client.py | 26 +++++++++++++++++--- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 547b23d2..432710d8 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -161,7 +161,9 @@ def create_note( params = {"detector_id": det_id} headers = {"x-api-token": self.configuration.api_key["ApiToken"]} - response = requests.post(url, headers=headers, data=data, files=files, params=params) # type: ignore + response = self.api_client.request_with_unauthorized_recovery( + "POST", url, headers=headers, data=data, files=files, params=params + ) response.raise_for_status() # Raise an exception for error status codes def reset_detector(self, detector: Union[str, Detector]) -> None: diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index e98d525c..aaa553e5 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -179,23 +179,33 @@ def set_unauthorized_handler(self, handler: Callable[[], None]) -> None: self._unauthorized_handler = handler @staticmethod - def _prepare_replayable_request_body(args: tuple) -> Tuple[tuple, Optional[bytes]]: + def _prepare_replayable_request_body(args: tuple, kwargs: dict) -> Tuple[tuple, dict, Optional[bytes], bool]: """Copy a stream body so each request attempt receives a fresh stream.""" - if len(args) <= REQUEST_BODY_ARG_INDEX or not isinstance(args[REQUEST_BODY_ARG_INDEX], io.IOBase): - return args, None - body = args[REQUEST_BODY_ARG_INDEX] + body_is_keyword = "body" in kwargs + body = ( + kwargs.get("body") + if body_is_keyword + else (args[REQUEST_BODY_ARG_INDEX] if len(args) > REQUEST_BODY_ARG_INDEX else None) + ) + if not isinstance(body, io.IOBase): + return args, kwargs, None, body_is_keyword try: body_bytes = body.read() finally: body.close() - replayable_args = list(args) - replayable_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(body_bytes) - return tuple(replayable_args), body_bytes + if body_is_keyword: + kwargs = dict(kwargs) + kwargs["body"] = io.BytesIO(body_bytes) + else: + replayable_args = list(args) + replayable_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(body_bytes) + args = tuple(replayable_args) + return args, kwargs, body_bytes, body_is_keyword @RequestsRetryDecorator() def call_api(self, *args, **kwargs): """Add a request ID and retry once after token recovery from a 401.""" - args, replayable_body = self._prepare_replayable_request_body(args) + args, kwargs, replayable_body, body_is_keyword = self._prepare_replayable_request_body(args, kwargs) # Note we don't look for header_param in kwargs here, because this method is only called in one place # in the generated code, so we can afford to make this brittle. header_param = args[4] # that's the number in the list @@ -212,9 +222,13 @@ def call_api(self, *args, **kwargs): raise self._unauthorized_handler() retry_args = list(args) + retry_kwargs = dict(kwargs) if replayable_body is not None: - retry_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(replayable_body) - return super().call_api(*retry_args, **kwargs) + if body_is_keyword: + retry_kwargs["body"] = io.BytesIO(replayable_body) + else: + retry_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(replayable_body) + return super().call_api(*retry_args, **retry_kwargs) def request_with_unauthorized_recovery(self, method: str, url: str, **kwargs) -> requests.Response: """Send a raw request and retry once with refreshed credentials after a 401.""" diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 65a19313..ef698a2e 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -31,7 +31,7 @@ TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 TOKEN_PAGE_SIZE = 100 -LOCK_TIMEOUT_SECONDS = 5 +LOCK_TIMEOUT_SECONDS = 60 REFRESH_INTERVAL_SECONDS = timedelta(days=REFRESH_INTERVAL_DAYS).total_seconds() @@ -227,7 +227,7 @@ def close(self) -> None: """Stop background refresh work and close its API client.""" self._stop_event.set() if self._thread is not None: - self._thread.join(timeout=self._request_timeout + 1) + self._thread.join() self._rotation_client.close() def recover_from_unauthorized(self) -> None: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 2091f969..6181abec 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -196,6 +196,22 @@ def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): assert stop_event.wait.call_args_list == [call(0.0), call(REFRESH_INTERVAL_SECONDS)] +def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): + """Closing waits for in-flight refresh work before closing its HTTP client.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + thread = Mock() + manager._thread = thread + rotation_client_close = mocker.patch.object(manager._rotation_client, "close") + + manager.close() + + thread.join.assert_called_once_with() + rotation_client_close.assert_called_once_with() + + def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): """A 401 reloads a token another process already wrote instead of minting again.""" api = Mock() diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index 4cc2486d..b282841f 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from groundlight.client import Groundlight +from groundlight.experimental_api import ExperimentalApi from groundlight.internalapi import GroundlightApiClient from groundlight_openapi_client import Configuration from groundlight_openapi_client.api_client import ApiClient, ApiException @@ -49,10 +50,10 @@ def test_api_client_replays_stream_body_after_unauthorized(mocker): client.set_unauthorized_handler(Mock()) request_bodies = [] - def call_parent(*args, **_kwargs): + def call_parent(*_args, **kwargs): """Consume each stream like the generated API client does.""" - request_bodies.append(args[5].read()) - args[5].close() + request_bodies.append(kwargs["body"].read()) + kwargs["body"].close() if len(request_bodies) == 1: raise ApiException(status=401) return "success" @@ -60,7 +61,7 @@ def call_parent(*args, **_kwargs): mocker.patch.object(ApiClient, "call_api", side_effect=call_parent) body = BytesIO(b"image bytes") - result = client.call_api("/v1/image-queries", "POST", {}, [], {}, body) + result = client.call_api("/v1/image-queries", "POST", {}, [], {}, body=body) assert result == "success" assert body.closed @@ -89,3 +90,20 @@ def refresh_token() -> None: assert response is success assert request.call_count == EXPECTED_CALL_COUNT assert request.call_args_list[1].kwargs["headers"]["x-api-token"] == "new-token" + + +def test_create_note_uses_raw_request_token_recovery(): + """Note creation routes its multipart request through token recovery.""" + client = ExperimentalApi.__new__(ExperimentalApi) + client.endpoint = "https://example.com/device-api" + client.configuration = Configuration(host=client.endpoint) + client.configuration.api_key["ApiToken"] = "old-token" + client.api_client = Mock() + client.api_client.request_with_unauthorized_recovery.return_value = Mock() + + client.create_note("detector-id", "note text", image=b"image bytes") + + request = client.api_client.request_with_unauthorized_recovery + request.assert_called_once() + assert request.call_args.args[:2] == ("POST", "https://example.com/device-api/v1/notes") + assert request.call_args.kwargs["files"]["image"][1].read() == b"image bytes" From c2be003efdf87b2a362a7408b2b64966d11729b1 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 15:06:21 -0700 Subject: [PATCH 06/30] Revoke bootstrap token after initial working token is minted The bootstrap token has only one job: mint the first working token. Keeping it alive afterward is a security risk -- a leaked bootstrap can silently mint tokens indefinitely. Revoke it immediately after the first slot is written. Consequences: - 401 recovery no longer falls back to bootstrap to re-mint; it adopts a fresher cached token from another process or raises loudly requiring human intervention (provision a new bootstrap token). - refresh() raises on a missing slot rather than re-minting from bootstrap, since the bootstrap is gone. Updates plan doc and Google Doc accordingly. Co-authored-by: Cursor --- src/groundlight/token_manager.py | 87 ++++++++++++++++++++++++-------- test/unit/test_token_manager.py | 82 ++++++++++++++++++++++-------- 2 files changed, 125 insertions(+), 44 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index ef698a2e..cacbb79c 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -25,14 +25,22 @@ logger = logging.getLogger("groundlight.sdk") TOKEN_SNIPPET_LENGTH = 20 -TOKEN_TTL_DAYS = 30 -REFRESH_INTERVAL_DAYS = 1 +# TODO(GL-1709): TEMPORARY short-lived values for live rotation testing only. +# Revert to TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1 before merging. +TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): revert to 30 (temporarily 3 minutes for testing) +REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): revert to 1 (temporarily 1 minute for testing) CLEANUP_GRACE_FACTOR = 2 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 TOKEN_PAGE_SIZE = 100 LOCK_TIMEOUT_SECONDS = 60 -REFRESH_INTERVAL_SECONDS = timedelta(days=REFRESH_INTERVAL_DAYS).total_seconds() +# After a failed background refresh the current token is still valid for the rest of its +# TTL, so retry on a short cadence to recover quickly from a transient outage rather than +# waiting a full refresh interval (which would also spin when the token is already overdue). +REFRESH_RETRY_BACKOFF_SECONDS = 5 * 60 +# Matches the trailing " xxxxxx" hex suffix this class appends, so repeated rotations +# reuse a stable base name instead of accreting a new suffix each cycle. +TOKEN_NAME_SUFFIX_PATTERN = re.compile(r" [0-9a-f]{6}$") class TokenManagerError(RuntimeError): @@ -181,16 +189,20 @@ def _default_token_dir() -> Path: return Path.home() / ".groundlight" / "tokens" def _ensure_token_dir(self) -> None: - """Create the token directory or raise a clear configuration error.""" + """Create the token directory, tighten its permissions, or raise a clear error.""" try: self._token_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + # mkdir does not alter an existing directory's mode, so tighten it explicitly: + # cached tokens are secrets and must not be group- or world-readable. + if os.name != "nt": + os.chmod(self._token_dir, 0o700) except OSError as exc: raise TokenManagerError(f"Cannot create Groundlight token directory '{self._token_dir}': {exc}") from exc if not os.access(self._token_dir, os.W_OK): raise TokenManagerError(f"Groundlight token directory '{self._token_dir}' is not writable") def _initialize_token(self) -> None: - """Load a valid cached token or mint one with the bootstrap token.""" + """Load a valid cached token or mint one from the bootstrap token, then revoke the bootstrap.""" try: with self._lock: slot = self._load_slot() @@ -199,6 +211,9 @@ def _initialize_token(self) -> None: return self._set_api_token(self._bootstrap_token) self._mint_replacement(slot, record_replaced_current=False) + # Bootstrap token has served its only purpose. Revoke it now so a leaked + # bootstrap token cannot be used to mint tokens in the future. + self._revoke_bootstrap() except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc except NotFoundException: @@ -212,6 +227,26 @@ def _initialize_token(self) -> None: except Exception as exc: raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc + def _revoke_bootstrap(self) -> None: + """Revoke the bootstrap token after the first working token has been persisted. + + Failure is logged as a warning but not raised: the working token is already active, + so a failed revocation is not fatal. The bootstrap token will eventually expire on + its own TTL. + """ + try: + bootstrap_meta = self._find_token_by_snippet(self._bootstrap_snippet) + if bootstrap_meta is None: + logger.debug("Bootstrap token snippet not found in token list; it may already be revoked.") + return + self._api_tokens.delete_api_token(bootstrap_meta.name, _request_timeout=self._request_timeout) + logger.info("Bootstrap API token '%s' revoked after initial working token was minted.", bootstrap_meta.name) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "Failed to revoke bootstrap token; it remains active until it expires naturally.", + exc_info=True, + ) + def start(self) -> None: """Start background refresh when the server supports token management.""" if not self._available or self._thread is not None: @@ -231,7 +266,12 @@ def close(self) -> None: self._rotation_client.close() def recover_from_unauthorized(self) -> None: - """Reload a newer cached token or re-mint using the bootstrap token.""" + """Recover from a 401 by loading a fresher cached token written by another process. + + The bootstrap token has already been revoked and is never used as a fallback here. + If no fresher token is available on disk, the working token chain is broken and + requires human intervention (provision a new bootstrap token). + """ if not self._available: raise TokenManagerError("Automatic token recovery is unavailable on this server") failed_token = self._configuration.api_key["ApiToken"] @@ -241,22 +281,17 @@ def recover_from_unauthorized(self) -> None: if slot and slot.current.raw_key != failed_token and slot.current.expires_at > _utc_now(): self._activate(slot.current) return - self._set_api_token(self._bootstrap_token) - self._mint_replacement(slot, record_replaced_current=False) - except NotFoundException: - logger.warning( - "Automatic API token refresh is unavailable because this server does not support token management" - ) - self._available = False - self._stop_event.set() - self._set_api_token(self._bootstrap_token) + raise TokenManagerError( + "The working API token was rejected and no fresher cached token is available. " + "Please provision a new GROUNDLIGHT_API_TOKEN." + ) except FileLockTimeout as exc: raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc except TokenManagerError: raise except Exception as exc: raise TokenManagerError( - "The cached token was rejected and the bootstrap token could not replace it" + "The cached token was rejected and could not be replaced" ) from exc def refresh(self) -> bool: @@ -265,9 +300,12 @@ def refresh(self) -> bool: with self._lock: slot = self._load_slot() if slot is None: - self._set_api_token(self._bootstrap_token) - self._mint_replacement(None) - return True + # The slot file was lost. The bootstrap token has already been revoked so + # recovery is not possible without human intervention. + raise TokenManagerError( + "Token cache slot is missing and the bootstrap token has been revoked. " + "Please provision a new GROUNDLIGHT_API_TOKEN." + ) self._activate(slot.current) if _utc_now() - slot.current.minted_at < timedelta(days=REFRESH_INTERVAL_DAYS): @@ -303,7 +341,7 @@ def _run(self) -> None: except Exception: # pylint: disable=broad-exception-caught logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) refresh_succeeded = False - if not refresh_succeeded and self._stop_event.wait(REFRESH_INTERVAL_SECONDS): + if not refresh_succeeded and self._stop_event.wait(REFRESH_RETRY_BACKOFF_SECONDS): return def _load_slot(self) -> Optional[TokenSlot]: @@ -400,12 +438,17 @@ def _cleanup_previous(self, previous: Optional[PreviousToken]) -> bool: @staticmethod def _new_token_name(base_name: Optional[str]) -> str: - """Create a readable, unique name that fits the API length limit.""" + """Create a readable, unique name that fits the API length limit. + + Strips any existing hex suffix from the base name first so a token rotated many + times stays "My Token a7f3c2" rather than growing "My Token a7f3c2 b8e4d1 ...". + """ suffix = secrets.token_hex(3) if not base_name: return f"sdk-auto {suffix}" + stripped_base = TOKEN_NAME_SUFFIX_PATTERN.sub("", base_name) max_base_length = TOKEN_NAME_MAX_LENGTH - TOKEN_NAME_SUFFIX_LENGTH - return f"{base_name[:max_base_length]} {suffix}" + return f"{stripped_base[:max_base_length]} {suffix}" @staticmethod def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 6181abec..77fa0d00 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -9,7 +9,7 @@ from groundlight import token_manager from groundlight.token_manager import ( REFRESH_INTERVAL_DAYS, - REFRESH_INTERVAL_SECONDS, + REFRESH_RETRY_BACKOFF_SECONDS, TOKEN_NAME_MAX_LENGTH, TOKEN_TTL_DAYS, TokenManager, @@ -21,6 +21,7 @@ BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) TOKEN_CACHE_MODE = 0o600 +TOKEN_DIR_MODE = 0o700 EXPECTED_PAGE_COUNT = 2 @@ -76,6 +77,17 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): assert cached["previous"] is None +def test_initialization_revokes_bootstrap_token_after_first_mint(mocker, tmp_path): + """After minting the first working token, the bootstrap token is deleted.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + + _manager(mocker, tmp_path, api) + + api.delete_api_token.assert_called_once_with("Device token", _request_timeout=1) + + def test_initialization_reuses_valid_cached_token(mocker, tmp_path): """A valid slot is reused without making token API calls.""" first_api = Mock() @@ -114,14 +126,15 @@ def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): long_name = "x" * 64 api.list_api_tokens.side_effect = [ _page([_metadata("Other token", "api_other_token_value")], "https://example.com/page=2"), - _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), + _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # pagination for mint name lookup + _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # for bootstrap revocation lookup ] api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") _manager(mocker, tmp_path, api) - assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT + assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT + 1 # +1 for revocation lookup request = api.create_api_token.call_args.args[0] assert request.name == f"{'x' * 57} abc123" assert len(request.name) == TOKEN_NAME_MAX_LENGTH @@ -133,7 +146,7 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - + api.reset_mock() # clear calls from init (mint + bootstrap revocation) old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", @@ -179,7 +192,7 @@ def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): - """A failed refresh waits one interval instead of immediately retrying.""" + """A failed refresh waits a short backoff instead of immediately retrying or waiting a full day.""" api = Mock() api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) @@ -193,7 +206,7 @@ def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): manager._run() - assert stop_event.wait.call_args_list == [call(0.0), call(REFRESH_INTERVAL_SECONDS)] + assert stop_event.wait.call_args_list == [call(0.0), call(REFRESH_RETRY_BACKOFF_SECONDS)] def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): @@ -231,28 +244,53 @@ def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): api.create_api_token.assert_not_called() -def test_unauthorized_recovery_mints_with_bootstrap_token(mocker, tmp_path): - """A rejected cached token is replaced using the unchanged bootstrap credential.""" +def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): + """A rejected cached token raises loudly when no fresher token is on disk.""" api = Mock() api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - api.reset_mock() - slot = json.loads(manager._slot_path.read_text()) - slot["previous"] = { - "name": "older token", - "minted_at": (NOW - timedelta(days=1)).isoformat(), - } - manager._slot_path.write_text(json.dumps(slot)) - api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) - api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", NOW) - manager.recover_from_unauthorized() + with pytest.raises(TokenManagerError, match="provision a new GROUNDLIGHT_API_TOKEN"): + manager.recover_from_unauthorized() - api.create_api_token.assert_called_once() - assert manager._configuration.api_key["ApiToken"] == "api_working_token_two" - cached = json.loads(manager._slot_path.read_text()) - assert cached["previous"]["name"] == "older token" + api.create_api_token.assert_called_once() # only during init, not during recovery + + +def test_new_token_name_strips_existing_suffix(mocker): + """Rotating a token reuses its base name instead of accreting a new hex suffix each cycle.""" + mocker.patch.object(token_manager.secrets, "token_hex", return_value="def456") + + assert TokenManager._new_token_name("Device token abc123") == "Device token def456" + assert TokenManager._new_token_name("Device token") == "Device token def456" + assert TokenManager._new_token_name(None) == "sdk-auto def456" + + +def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): + """An over-permissive existing token directory is tightened to 0700 during initialization.""" + loose_dir = tmp_path / "tokens" + loose_dir.mkdir() + loose_dir.chmod(0o777) # noqa: S103 # intentionally over-permissive to prove it gets tightened + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + + _manager(mocker, loose_dir, api) + + assert stat.S_IMODE(loose_dir.stat().st_mode) == TOKEN_DIR_MODE + + +def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, tmp_path): + """A failed 401 recovery raises loudly and leaves the active token unchanged.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + + with pytest.raises(TokenManagerError, match="provision a new GROUNDLIGHT_API_TOKEN"): + manager.recover_from_unauthorized() + + assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): From 0be78373ccc1872d196a25412760eab14266659f Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 9 Jul 2026 22:07:32 +0000 Subject: [PATCH 07/30] Automatically reformatting code --- src/groundlight/token_manager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index cacbb79c..ff79ce83 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -290,9 +290,7 @@ def recover_from_unauthorized(self) -> None: except TokenManagerError: raise except Exception as exc: - raise TokenManagerError( - "The cached token was rejected and could not be replaced" - ) from exc + raise TokenManagerError("The cached token was rejected and could not be replaced") from exc def refresh(self) -> bool: """Refresh the working token, returning whether the cycle completed successfully.""" From 147ce6807ee866a172fd8d618d2384a485802cb1 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 9 Jul 2026 15:32:56 -0700 Subject: [PATCH 08/30] Store base_name in slot cache to eliminate repeated token lookups during rotation The bootstrap token's name (suffix-stripped) is now written into the slot cache as base_name once, at first mint. Subsequent rotations read it from the slot instead of doing a paginated list call to rediscover the current token's name. Key changes: - TokenSlot gains a base_name field; old slots with no field fall back to a live lookup via the new _resolve_base_name helper. - _initialize_token looks up the bootstrap token once; the result seeds base_name and is passed directly to _revoke_bootstrap, eliminating the second paginated scan that revocation previously required. - _mint_replacement now receives base_name as a parameter rather than doing its own lookup. - _new_token_name simplified: it no longer accepts None or strips suffixes (that responsibility moved to _resolve_base_name). Co-authored-by: Cursor --- src/groundlight/token_manager.py | 79 +++++++++++++++++++++----------- test/unit/test_token_manager.py | 64 ++++++++++++++++++++++---- 2 files changed, 106 insertions(+), 37 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index ff79ce83..a6a345ff 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -123,8 +123,15 @@ def to_dict(self) -> Dict[str, str]: @dataclass(frozen=True) class TokenSlot: - """Represent the current and previous tokens stored in one cache slot.""" + """Represent the current and previous tokens stored in one cache slot. + base_name is the bootstrap token's human-readable name with any auto-generated suffix + stripped. It is established once at first mint and reused for all future rotations, + so a token chain always looks like "My Sensor ab12cd", "My Sensor ef34gh", ... rather + than accumulating nested suffixes. + """ + + base_name: str current: CurrentToken previous: Optional[PreviousToken] = None @@ -133,6 +140,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "TokenSlot": """Build a token slot from its on-disk representation.""" previous_data = data.get("previous") return cls( + # Empty string for slots written before this field existed; callers fall back + # to a live lookup when base_name is empty. + base_name=data.get("base_name", ""), current=CurrentToken.from_dict(data["current"]), previous=PreviousToken.from_dict(previous_data) if previous_data else None, ) @@ -140,6 +150,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "TokenSlot": def to_dict(self) -> Dict[str, Any]: """Convert the token slot to its JSON-compatible representation.""" return { + "base_name": self.base_name, "current": self.current.to_dict(), "previous": self.previous.to_dict() if self.previous else None, } @@ -210,10 +221,14 @@ def _initialize_token(self) -> None: self._activate(slot.current) return self._set_api_token(self._bootstrap_token) - self._mint_replacement(slot, record_replaced_current=False) + # Look up the bootstrap token once: its name seeds base_name for every future + # rotation, and we need its exact API name to revoke it immediately after. + bootstrap_meta = self._find_token_by_snippet(self._bootstrap_snippet) + base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", bootstrap_meta.name) if bootstrap_meta else "sdk-auto" + self._mint_replacement(base_name=base_name, slot=slot, record_replaced_current=False) # Bootstrap token has served its only purpose. Revoke it now so a leaked # bootstrap token cannot be used to mint tokens in the future. - self._revoke_bootstrap() + self._revoke_bootstrap(bootstrap_meta.name if bootstrap_meta else None) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc except NotFoundException: @@ -227,20 +242,19 @@ def _initialize_token(self) -> None: except Exception as exc: raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc - def _revoke_bootstrap(self) -> None: + def _revoke_bootstrap(self, bootstrap_api_name: Optional[str]) -> None: """Revoke the bootstrap token after the first working token has been persisted. Failure is logged as a warning but not raised: the working token is already active, so a failed revocation is not fatal. The bootstrap token will eventually expire on its own TTL. """ + if not bootstrap_api_name: + logger.debug("Bootstrap token was not found in the token list; it may already be revoked.") + return try: - bootstrap_meta = self._find_token_by_snippet(self._bootstrap_snippet) - if bootstrap_meta is None: - logger.debug("Bootstrap token snippet not found in token list; it may already be revoked.") - return - self._api_tokens.delete_api_token(bootstrap_meta.name, _request_timeout=self._request_timeout) - logger.info("Bootstrap API token '%s' revoked after initial working token was minted.", bootstrap_meta.name) + self._api_tokens.delete_api_token(bootstrap_api_name, _request_timeout=self._request_timeout) + logger.info("Bootstrap API token '%s' revoked after initial working token was minted.", bootstrap_api_name) except Exception: # pylint: disable=broad-exception-caught logger.warning( "Failed to revoke bootstrap token; it remains active until it expires naturally.", @@ -310,7 +324,9 @@ def refresh(self) -> bool: return True if not self._cleanup_previous(slot.previous): return False - self._mint_replacement(slot) + # Read base_name from slot; fall back to a live lookup for old-format slots. + base_name = slot.base_name or self._resolve_base_name(slot.current.snippet) + self._mint_replacement(base_name=base_name, slot=slot) return True except NotFoundException: logger.warning( @@ -376,11 +392,15 @@ def _write_slot(self, slot: TokenSlot) -> None: if temporary_path and os.path.exists(temporary_path): os.unlink(temporary_path) - def _mint_replacement(self, slot: Optional[TokenSlot], *, record_replaced_current: bool = True) -> CurrentToken: - """Mint, persist, and activate a replacement for the supplied slot.""" - source_snippet = slot.current.snippet if slot else self._bootstrap_snippet - source_token = self._find_token_by_snippet(source_snippet) - new_name = self._new_token_name(source_token.name if source_token else None) + def _mint_replacement( + self, + base_name: str, + slot: Optional[TokenSlot], + *, + record_replaced_current: bool = True, + ) -> CurrentToken: + """Mint a new token, persist the updated slot, and activate the new credential.""" + new_name = self._new_token_name(base_name) minted_at = _utc_now() response = self._api_tokens.create_api_token( ApiTokenRequest(name=new_name, expires_at=minted_at + timedelta(days=TOKEN_TTL_DAYS)), @@ -392,7 +412,7 @@ def _mint_replacement(self, slot: Optional[TokenSlot], *, record_replaced_curren previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) else: previous = slot.previous if slot else None - self._write_slot(TokenSlot(current=current, previous=previous)) + self._write_slot(TokenSlot(base_name=base_name, current=current, previous=previous)) self._activate(current) return current @@ -413,6 +433,18 @@ def _find_token_by_snippet(self, snippet: str) -> Optional[ApiToken]: return None page += 1 + def _resolve_base_name(self, snippet: str) -> str: + """Look up a token by snippet and return its name with any auto-generated suffix stripped. + + Used when establishing the base_name for a new token chain (initial mint) or when + reading an old-format slot file that predates the base_name field. + Falls back to 'sdk-auto' when no matching token is found. + """ + token = self._find_token_by_snippet(snippet) + if token is None: + return "sdk-auto" + return TOKEN_NAME_SUFFIX_PATTERN.sub("", token.name) + def _get_token_by_snippet(self, snippet: str) -> ApiToken: """Retrieve token metadata through the dedicated snippet endpoint.""" # TODO: Before merging, switch _find_token_by_snippet callers to this method once the endpoint is live. @@ -435,18 +467,11 @@ def _cleanup_previous(self, previous: Optional[PreviousToken]) -> bool: return True @staticmethod - def _new_token_name(base_name: Optional[str]) -> str: - """Create a readable, unique name that fits the API length limit. - - Strips any existing hex suffix from the base name first so a token rotated many - times stays "My Token a7f3c2" rather than growing "My Token a7f3c2 b8e4d1 ...". - """ + def _new_token_name(base_name: str) -> str: + """Append a unique 6-character hex suffix to base_name, truncating to fit the column limit.""" suffix = secrets.token_hex(3) - if not base_name: - return f"sdk-auto {suffix}" - stripped_base = TOKEN_NAME_SUFFIX_PATTERN.sub("", base_name) max_base_length = TOKEN_NAME_MAX_LENGTH - TOKEN_NAME_SUFFIX_LENGTH - return f"{stripped_base[:max_base_length]} {suffix}" + return f"{base_name[:max_base_length]} {suffix}" @staticmethod def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 77fa0d00..21dd41a1 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -73,6 +73,7 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" assert stat.S_IMODE(manager._slot_path.stat().st_mode) == TOKEN_CACHE_MODE cached = json.loads(manager._slot_path.read_text()) + assert cached["base_name"] == "Device token" assert cached["current"]["raw_key"] == "api_working_token_one" assert cached["previous"] is None @@ -121,20 +122,20 @@ def test_initialization_uses_bootstrap_when_token_api_is_unavailable(mocker, tmp def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): - """Token naming finds the matching snippet on later pages and stays within 64 characters.""" + """Token naming finds the matching snippet on a later page and stays within 64 characters.""" api = Mock() long_name = "x" * 64 api.list_api_tokens.side_effect = [ _page([_metadata("Other token", "api_other_token_value")], "https://example.com/page=2"), - _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # pagination for mint name lookup - _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # for bootstrap revocation lookup + _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # bootstrap found on page 2 ] api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") _manager(mocker, tmp_path, api) - assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT + 1 # +1 for revocation lookup + # One paginated scan to find the bootstrap token; result is reused for naming and revocation. + assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT request = api.create_api_token.call_args.args[0] assert request.name == f"{'x' * 57} abc123" assert len(request.name) == TOKEN_NAME_MAX_LENGTH @@ -146,7 +147,7 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - api.reset_mock() # clear calls from init (mint + bootstrap revocation) + api.reset_mock() # clear calls from init (bootstrap lookup + mint + revocation) old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", @@ -155,13 +156,15 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): manager._slot_path.write_text(json.dumps(old_slot)) later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) mocker.patch.object(token_manager, "_utc_now", return_value=later) - api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) manager.refresh() + # Rotation reads base_name from the slot; no list call is made. + api.list_api_tokens.assert_not_called() api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) cached = json.loads(manager._slot_path.read_text()) + assert cached["base_name"] == "Device token" assert cached["current"]["raw_key"] == "api_working_token_two" assert cached["previous"]["name"] == "Device token abc123" @@ -257,13 +260,26 @@ def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tm api.create_api_token.assert_called_once() # only during init, not during recovery -def test_new_token_name_strips_existing_suffix(mocker): - """Rotating a token reuses its base name instead of accreting a new hex suffix each cycle.""" +def test_new_token_name_appends_suffix_and_truncates(mocker): + """Token names append a unique hex suffix and never exceed the column limit.""" mocker.patch.object(token_manager.secrets, "token_hex", return_value="def456") - assert TokenManager._new_token_name("Device token abc123") == "Device token def456" assert TokenManager._new_token_name("Device token") == "Device token def456" - assert TokenManager._new_token_name(None) == "sdk-auto def456" + assert TokenManager._new_token_name("x" * 64) == f"{'x' * 57} def456" + assert len(TokenManager._new_token_name("x" * 100)) == TOKEN_NAME_MAX_LENGTH + + +def test_resolve_base_name_strips_existing_suffix(mocker, tmp_path): + """The base_name established from an existing token has any prior hex suffix stripped.""" + api = Mock() + # Bootstrap token already has a suffix from a previous rotation cycle. + api.list_api_tokens.return_value = _page([_metadata("Device token abc123", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_one", NOW) + + _manager(mocker, tmp_path, api) + + cached = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + assert cached["base_name"] == "Device token" def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): @@ -299,3 +315,31 @@ def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): with pytest.raises(TokenManagerError, match="invalid format"): TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) + + +def test_refresh_falls_back_to_lookup_for_old_format_slot(mocker, tmp_path): + """A slot written before the base_name field existed triggers a live lookup on the next refresh.""" + api = Mock() + api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + + # Simulate a slot written by an older SDK version (no base_name field). + slot_data = json.loads(manager._slot_path.read_text()) + del slot_data["base_name"] + manager._slot_path.write_text(json.dumps(slot_data)) + + later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=later) + api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) + + manager.refresh() + + # Fallback lookup was needed because base_name was absent. + api.list_api_tokens.assert_called() + cached = json.loads(manager._slot_path.read_text()) + # After the refresh the slot has base_name for future rotations. + assert cached["base_name"] == "Device token" + assert cached["current"]["raw_key"] == "api_working_token_two" From 1738546783c36934913b5420fc947188d413aa22 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 10 Jul 2026 11:13:45 -0700 Subject: [PATCH 09/30] Switch to by-snippet endpoint and revert temporary TTL values The GET /v1/api-tokens/by-snippet/ endpoint (zuuul#6579) is now deployed. Replace the paginated list scan with a single direct call, removing _find_token_by_snippet and TOKEN_PAGE_SIZE entirely. - _initialize_token and _resolve_base_name both call _get_token_by_snippet; NotFoundException means "token not found" and falls back gracefully. - Revert TOKEN_TTL_DAYS to 30 and REFRESH_INTERVAL_DAYS to 1 (temporary short values were only for live rotation testing). - Update tests: list_api_tokens mocks replaced with get_api_token_by_snippet; _page helper and EXPECTED_PAGE_COUNT removed. Co-authored-by: Cursor --- src/groundlight/token_manager.py | 48 ++++++++--------------- test/unit/test_token_manager.py | 66 ++++++++++++++------------------ 2 files changed, 44 insertions(+), 70 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index a6a345ff..2fc5c856 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -25,14 +25,11 @@ logger = logging.getLogger("groundlight.sdk") TOKEN_SNIPPET_LENGTH = 20 -# TODO(GL-1709): TEMPORARY short-lived values for live rotation testing only. -# Revert to TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1 before merging. -TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): revert to 30 (temporarily 3 minutes for testing) -REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): revert to 1 (temporarily 1 minute for testing) +TOKEN_TTL_DAYS = 30 +REFRESH_INTERVAL_DAYS = 1 CLEANUP_GRACE_FACTOR = 2 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 -TOKEN_PAGE_SIZE = 100 LOCK_TIMEOUT_SECONDS = 60 # After a failed background refresh the current token is still valid for the rest of its # TTL, so retry on a short cadence to recover quickly from a transient outage rather than @@ -223,12 +220,17 @@ def _initialize_token(self) -> None: self._set_api_token(self._bootstrap_token) # Look up the bootstrap token once: its name seeds base_name for every future # rotation, and we need its exact API name to revoke it immediately after. - bootstrap_meta = self._find_token_by_snippet(self._bootstrap_snippet) - base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", bootstrap_meta.name) if bootstrap_meta else "sdk-auto" + try: + bootstrap_meta = self._get_token_by_snippet(self._bootstrap_snippet) + base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", bootstrap_meta.name) + bootstrap_api_name: Optional[str] = bootstrap_meta.name + except NotFoundException: + base_name = "sdk-auto" + bootstrap_api_name = None self._mint_replacement(base_name=base_name, slot=slot, record_replaced_current=False) # Bootstrap token has served its only purpose. Revoke it now so a leaked # bootstrap token cannot be used to mint tokens in the future. - self._revoke_bootstrap(bootstrap_meta.name if bootstrap_meta else None) + self._revoke_bootstrap(bootstrap_api_name) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc except NotFoundException: @@ -416,38 +418,20 @@ def _mint_replacement( self._activate(current) return current - def _find_token_by_snippet(self, snippet: str) -> Optional[ApiToken]: - """Find a token by iterating through all pages of token metadata.""" - # TODO: Before merging, replace this paginated lookup with _get_token_by_snippet once that endpoint is live. - page = 1 - while True: - response = self._api_tokens.list_api_tokens( - page=page, - page_size=TOKEN_PAGE_SIZE, - _request_timeout=self._request_timeout, - ) - for token in response.results: - if token.raw_key_snippet == snippet: - return token - if not response.next: - return None - page += 1 - def _resolve_base_name(self, snippet: str) -> str: """Look up a token by snippet and return its name with any auto-generated suffix stripped. - Used when establishing the base_name for a new token chain (initial mint) or when - reading an old-format slot file that predates the base_name field. + Used when reading an old-format slot file that predates the base_name field. Falls back to 'sdk-auto' when no matching token is found. """ - token = self._find_token_by_snippet(snippet) - if token is None: + try: + token = self._get_token_by_snippet(snippet) + return TOKEN_NAME_SUFFIX_PATTERN.sub("", token.name) + except NotFoundException: return "sdk-auto" - return TOKEN_NAME_SUFFIX_PATTERN.sub("", token.name) def _get_token_by_snippet(self, snippet: str) -> ApiToken: - """Retrieve token metadata through the dedicated snippet endpoint.""" - # TODO: Before merging, switch _find_token_by_snippet callers to this method once the endpoint is live. + """Retrieve token metadata by snippet via the dedicated API endpoint.""" return self._api_tokens.get_api_token_by_snippet(snippet, _request_timeout=self._request_timeout) def _cleanup_previous(self, previous: Optional[PreviousToken]) -> bool: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 21dd41a1..02a7a02c 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -22,11 +22,10 @@ NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) TOKEN_CACHE_MODE = 0o600 TOKEN_DIR_MODE = 0o700 -EXPECTED_PAGE_COUNT = 2 def _metadata(name: str, raw_key: str) -> SimpleNamespace: - """Build the token metadata returned by list operations.""" + """Build the token metadata returned by snippet or list operations.""" return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20]) @@ -40,11 +39,6 @@ def _created_token(name: str, raw_key: str, now: datetime) -> SimpleNamespace: ) -def _page(results, next_url=None) -> SimpleNamespace: - """Build one page of token metadata.""" - return SimpleNamespace(results=results, next=next_url) - - def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: """Create a token manager with deterministic API and time dependencies.""" mocker.patch.object(token_manager, "ApiTokensApi", return_value=api) @@ -62,7 +56,7 @@ def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): """A missing slot is minted from the bootstrap token and stored with mode 0600.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -81,7 +75,7 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): def test_initialization_revokes_bootstrap_token_after_first_mint(mocker, tmp_path): """After minting the first working token, the bootstrap token is deleted.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) _manager(mocker, tmp_path, api) @@ -92,7 +86,7 @@ def test_initialization_revokes_bootstrap_token_after_first_mint(mocker, tmp_pat def test_initialization_reuses_valid_cached_token(mocker, tmp_path): """A valid slot is reused without making token API calls.""" first_api = Mock() - first_api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + first_api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) first_api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) first = _manager(mocker, tmp_path, first_api) @@ -102,7 +96,7 @@ def test_initialization_reuses_valid_cached_token(mocker, tmp_path): configuration.api_key["ApiToken"] = BOOTSTRAP_TOKEN second = TokenManager(BOOTSTRAP_TOKEN, configuration, request_timeout=1, token_dir=tmp_path) - second_api.list_api_tokens.assert_not_called() + second_api.get_api_token_by_snippet.assert_not_called() second_api.create_api_token.assert_not_called() assert second._configuration.api_key["ApiToken"] == first._configuration.api_key["ApiToken"] @@ -110,32 +104,28 @@ def test_initialization_reuses_valid_cached_token(mocker, tmp_path): def test_initialization_uses_bootstrap_when_token_api_is_unavailable(mocker, tmp_path): """A server without token management remains usable with the bootstrap token.""" api = Mock() - api.list_api_tokens.side_effect = NotFoundException() + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.create_api_token.side_effect = NotFoundException() manager = _manager(mocker, tmp_path, api) manager.start() assert manager._configuration.api_key["ApiToken"] == BOOTSTRAP_TOKEN assert manager._thread is None - api.list_api_tokens.assert_called_once() - api.create_api_token.assert_not_called() + api.create_api_token.assert_called_once() -def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): - """Token naming finds the matching snippet on a later page and stays within 64 characters.""" +def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path): + """Token naming uses the by-snippet endpoint (no pagination) and stays within 64 characters.""" api = Mock() long_name = "x" * 64 - api.list_api_tokens.side_effect = [ - _page([_metadata("Other token", "api_other_token_value")], "https://example.com/page=2"), - _page([_metadata(long_name, BOOTSTRAP_TOKEN)]), # bootstrap found on page 2 - ] + api.get_api_token_by_snippet.return_value = _metadata(long_name, BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") _manager(mocker, tmp_path, api) - # One paginated scan to find the bootstrap token; result is reused for naming and revocation. - assert api.list_api_tokens.call_count == EXPECTED_PAGE_COUNT + api.get_api_token_by_snippet.assert_called_once_with(BOOTSTRAP_TOKEN[:20], _request_timeout=1) request = api.create_api_token.call_args.args[0] assert request.name == f"{'x' * 57} abc123" assert len(request.name) == TOKEN_NAME_MAX_LENGTH @@ -144,7 +134,7 @@ def test_name_lookup_follows_pagination_and_enforces_length(mocker, tmp_path): def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): """Scheduled refresh revokes an old previous token and records the replaced current token.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() # clear calls from init (bootstrap lookup + mint + revocation) @@ -160,8 +150,8 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): manager.refresh() - # Rotation reads base_name from the slot; no list call is made. - api.list_api_tokens.assert_not_called() + # Rotation reads base_name from the slot; no snippet endpoint call is made. + api.get_api_token_by_snippet.assert_not_called() api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) cached = json.loads(manager._slot_path.read_text()) assert cached["base_name"] == "Device token" @@ -172,7 +162,7 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path): """A failed cleanup postpones minting so the deletion can be retried later.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) slot = json.loads(manager._slot_path.read_text()) @@ -197,7 +187,7 @@ def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): """A failed refresh waits a short backoff instead of immediately retrying or waiting a full day.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) @@ -215,7 +205,7 @@ def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): """Closing waits for in-flight refresh work before closing its HTTP client.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) thread = Mock() @@ -231,7 +221,7 @@ def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): """A 401 reloads a token another process already wrote instead of minting again.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() @@ -250,7 +240,7 @@ def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): """A rejected cached token raises loudly when no fresher token is on disk.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -273,7 +263,7 @@ def test_resolve_base_name_strips_existing_suffix(mocker, tmp_path): """The base_name established from an existing token has any prior hex suffix stripped.""" api = Mock() # Bootstrap token already has a suffix from a previous rotation cycle. - api.list_api_tokens.return_value = _page([_metadata("Device token abc123", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token abc123", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_one", NOW) _manager(mocker, tmp_path, api) @@ -288,7 +278,7 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): loose_dir.mkdir() loose_dir.chmod(0o777) # noqa: S103 # intentionally over-permissive to prove it gets tightened api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) _manager(mocker, loose_dir, api) @@ -299,7 +289,7 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, tmp_path): """A failed 401 recovery raises loudly and leaves the active token unchanged.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -317,10 +307,10 @@ def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) -def test_refresh_falls_back_to_lookup_for_old_format_slot(mocker, tmp_path): - """A slot written before the base_name field existed triggers a live lookup on the next refresh.""" +def test_refresh_falls_back_to_snippet_endpoint_for_old_format_slot(mocker, tmp_path): + """A slot written before the base_name field existed triggers a snippet lookup on the next refresh.""" api = Mock() - api.list_api_tokens.return_value = _page([_metadata("Device token", BOOTSTRAP_TOKEN)]) + api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() @@ -332,13 +322,13 @@ def test_refresh_falls_back_to_lookup_for_old_format_slot(mocker, tmp_path): later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) mocker.patch.object(token_manager, "_utc_now", return_value=later) - api.list_api_tokens.return_value = _page([_metadata("Device token abc123", "api_working_token_one")]) + api.get_api_token_by_snippet.return_value = _metadata("Device token abc123", "api_working_token_one") api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) manager.refresh() # Fallback lookup was needed because base_name was absent. - api.list_api_tokens.assert_called() + api.get_api_token_by_snippet.assert_called_once() cached = json.loads(manager._slot_path.read_text()) # After the refresh the slot has base_name for future rotations. assert cached["base_name"] == "Device token" From 73a9c4783eeba682b52210ea8b1d014901e41c1f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 13 Jul 2026 13:40:16 -0700 Subject: [PATCH 10/30] adding testing values --- src/groundlight/token_manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 2fc5c856..1b84687e 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -25,8 +25,10 @@ logger = logging.getLogger("groundlight.sdk") TOKEN_SNIPPET_LENGTH = 20 -TOKEN_TTL_DAYS = 30 -REFRESH_INTERVAL_DAYS = 1 +# TODO(GL-1709): TEMPORARY short-lived values for live rotation testing only. +# Revert to TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1 before merging. +TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): revert to 30 (temporarily 3 minutes for testing) +REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): revert to 1 (temporarily 1 minute for testing) CLEANUP_GRACE_FACTOR = 2 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 From 9f015050a05032ae7a242b70d3aec99111245b8f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 18:40:42 +0000 Subject: [PATCH 11/30] Park bootstrap token as previous instead of revoking immediately Keep the configured token alive for delayed cleanup after the first working token is minted, and document the interim hardcoded testing TTL/refresh cadence until identity token_ttl discovery lands. Co-authored-by: Cursor --- src/groundlight/token_manager.py | 79 +++++++++++++++----------------- test/unit/test_token_manager.py | 26 +++++++---- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 1b84687e..339f5fef 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -26,9 +26,13 @@ TOKEN_SNIPPET_LENGTH = 20 # TODO(GL-1709): TEMPORARY short-lived values for live rotation testing only. -# Revert to TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1 before merging. -TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): revert to 30 (temporarily 3 minutes for testing) -REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): revert to 1 (temporarily 1 minute for testing) +# Before merge / production: set TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1. +# Follow-up (after zuuul exposes identity token_ttl on public /v1/api-tokens): +# - discover rotate-vs-not from by-snippet token_ttl (null => no refresh thread) +# - derive refresh_interval from mint expires_at as observed_ttl / 30 +# - stop hardcoding TOKEN_TTL_DAYS / REFRESH_INTERVAL_DAYS +TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): testing only (3 minutes); revert to 30 +REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): testing only (1 minute); revert to 1 CLEANUP_GRACE_FACTOR = 2 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 @@ -212,7 +216,7 @@ def _ensure_token_dir(self) -> None: raise TokenManagerError(f"Groundlight token directory '{self._token_dir}' is not writable") def _initialize_token(self) -> None: - """Load a valid cached token or mint one from the bootstrap token, then revoke the bootstrap.""" + """Load a valid cached token or mint one from the configured bootstrap token.""" try: with self._lock: slot = self._load_slot() @@ -221,18 +225,22 @@ def _initialize_token(self) -> None: return self._set_api_token(self._bootstrap_token) # Look up the bootstrap token once: its name seeds base_name for every future - # rotation, and we need its exact API name to revoke it immediately after. + # rotation, and we park that name as previous for delayed cleanup (not immediate revoke). try: bootstrap_meta = self._get_token_by_snippet(self._bootstrap_snippet) base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", bootstrap_meta.name) - bootstrap_api_name: Optional[str] = bootstrap_meta.name + bootstrap_previous: Optional[PreviousToken] = PreviousToken( + name=bootstrap_meta.name, minted_at=_utc_now() + ) except NotFoundException: base_name = "sdk-auto" - bootstrap_api_name = None - self._mint_replacement(base_name=base_name, slot=slot, record_replaced_current=False) - # Bootstrap token has served its only purpose. Revoke it now so a leaked - # bootstrap token cannot be used to mint tokens in the future. - self._revoke_bootstrap(bootstrap_api_name) + bootstrap_previous = None + self._mint_replacement( + base_name=base_name, + slot=slot, + record_replaced_current=False, + previous=bootstrap_previous, + ) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc except NotFoundException: @@ -246,25 +254,6 @@ def _initialize_token(self) -> None: except Exception as exc: raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc - def _revoke_bootstrap(self, bootstrap_api_name: Optional[str]) -> None: - """Revoke the bootstrap token after the first working token has been persisted. - - Failure is logged as a warning but not raised: the working token is already active, - so a failed revocation is not fatal. The bootstrap token will eventually expire on - its own TTL. - """ - if not bootstrap_api_name: - logger.debug("Bootstrap token was not found in the token list; it may already be revoked.") - return - try: - self._api_tokens.delete_api_token(bootstrap_api_name, _request_timeout=self._request_timeout) - logger.info("Bootstrap API token '%s' revoked after initial working token was minted.", bootstrap_api_name) - except Exception: # pylint: disable=broad-exception-caught - logger.warning( - "Failed to revoke bootstrap token; it remains active until it expires naturally.", - exc_info=True, - ) - def start(self) -> None: """Start background refresh when the server supports token management.""" if not self._available or self._thread is not None: @@ -286,9 +275,9 @@ def close(self) -> None: def recover_from_unauthorized(self) -> None: """Recover from a 401 by loading a fresher cached token written by another process. - The bootstrap token has already been revoked and is never used as a fallback here. - If no fresher token is available on disk, the working token chain is broken and - requires human intervention (provision a new bootstrap token). + Does not remint from the configured bootstrap token. If no fresher token is available + on disk, the working token chain is broken and requires human intervention + (provision a new GROUNDLIGHT_API_TOKEN). """ if not self._available: raise TokenManagerError("Automatic token recovery is unavailable on this server") @@ -316,10 +305,8 @@ def refresh(self) -> bool: with self._lock: slot = self._load_slot() if slot is None: - # The slot file was lost. The bootstrap token has already been revoked so - # recovery is not possible without human intervention. raise TokenManagerError( - "Token cache slot is missing and the bootstrap token has been revoked. " + "Token cache slot is missing and cannot be refreshed. " "Please provision a new GROUNDLIGHT_API_TOKEN." ) @@ -402,6 +389,7 @@ def _mint_replacement( slot: Optional[TokenSlot], *, record_replaced_current: bool = True, + previous: Optional[PreviousToken] = None, ) -> CurrentToken: """Mint a new token, persist the updated slot, and activate the new credential.""" new_name = self._new_token_name(base_name) @@ -411,12 +399,13 @@ def _mint_replacement( _request_timeout=self._request_timeout, ) current = self._current_from_response(response, minted_at) - previous: Optional[PreviousToken] - if slot and record_replaced_current: - previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) + if previous is not None: + next_previous = previous + elif slot and record_replaced_current: + next_previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) else: - previous = slot.previous if slot else None - self._write_slot(TokenSlot(base_name=base_name, current=current, previous=previous)) + next_previous = slot.previous if slot else None + self._write_slot(TokenSlot(base_name=base_name, current=current, previous=next_previous)) self._activate(current) return current @@ -463,12 +452,16 @@ def _new_token_name(base_name: str) -> str: def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: """Convert a token creation response into cached current-token data.""" if response.expires_at is None: - raise TokenManagerError("The server returned a minted API token without an expiration") + # TODO(GL-1709): null expires_at means a never-expire identity. Once token_ttl + # discovery lands, skip the refresh loop instead of inventing a client-side expiry. + expires_at = minted_at + timedelta(days=TOKEN_TTL_DAYS) + else: + expires_at = _normalize_datetime(response.expires_at) return CurrentToken( raw_key=response.raw_key, snippet=response.raw_key_snippet, name=response.name, - expires_at=_normalize_datetime(response.expires_at), + expires_at=expires_at, minted_at=minted_at, ) diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 02a7a02c..3d5aea0b 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -8,6 +8,7 @@ import pytest from groundlight import token_manager from groundlight.token_manager import ( + CLEANUP_GRACE_FACTOR, REFRESH_INTERVAL_DAYS, REFRESH_RETRY_BACKOFF_SECONDS, TOKEN_NAME_MAX_LENGTH, @@ -69,18 +70,22 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): cached = json.loads(manager._slot_path.read_text()) assert cached["base_name"] == "Device token" assert cached["current"]["raw_key"] == "api_working_token_one" - assert cached["previous"] is None + assert cached["previous"]["name"] == "Device token" + api.delete_api_token.assert_not_called() -def test_initialization_revokes_bootstrap_token_after_first_mint(mocker, tmp_path): - """After minting the first working token, the bootstrap token is deleted.""" +def test_initialization_parks_bootstrap_token_as_previous(mocker, tmp_path): + """After minting the first working token, the bootstrap token is parked as previous (not revoked).""" api = Mock() api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) - _manager(mocker, tmp_path, api) + manager = _manager(mocker, tmp_path, api) - api.delete_api_token.assert_called_once_with("Device token", _request_timeout=1) + api.delete_api_token.assert_not_called() + cached = json.loads(manager._slot_path.read_text()) + assert cached["previous"]["name"] == "Device token" + assert cached["previous"]["minted_at"] == NOW.isoformat().replace("+00:00", "Z") def test_initialization_reuses_valid_cached_token(mocker, tmp_path): @@ -137,11 +142,11 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - api.reset_mock() # clear calls from init (bootstrap lookup + mint + revocation) + api.reset_mock() # clear calls from init (bootstrap lookup + mint) old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", - "minted_at": (NOW - timedelta(days=2)).isoformat(), + "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), } manager._slot_path.write_text(json.dumps(old_slot)) later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) @@ -168,7 +173,7 @@ def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path slot = json.loads(manager._slot_path.read_text()) slot["previous"] = { "name": "older token", - "minted_at": (NOW - timedelta(days=2)).isoformat(), + "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), } manager._slot_path.write_text(json.dumps(slot)) mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) @@ -316,8 +321,13 @@ def test_refresh_falls_back_to_snippet_endpoint_for_old_format_slot(mocker, tmp_ api.reset_mock() # Simulate a slot written by an older SDK version (no base_name field). + # Age previous past cleanup grace so refresh can mint a replacement. slot_data = json.loads(manager._slot_path.read_text()) del slot_data["base_name"] + slot_data["previous"] = { + "name": "Device token", + "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), + } manager._slot_path.write_text(json.dumps(slot_data)) later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) From dcdcf3a4288e2ca61dd01f019a5a32ed9ba83d22 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 19:06:08 +0000 Subject: [PATCH 12/30] Fix CI: nullable last_used_at and TokenManager mypy typing Allow null last_used_at on API token responses so minting a never-used token does not fail OpenAPI deserialization during client init, and annotate next_previous for mypy. Co-authored-by: Cursor --- generated/docs/ApiToken.md | 2 +- generated/docs/ApiTokenCreateResponse.md | 2 +- .../groundlight_openapi_client/model/api_token.py | 5 ++++- .../model/api_token_create_response.py | 5 ++++- generated/model.py | 10 ++++++---- spec/public-api.yaml | 6 ++++-- src/groundlight/token_manager.py | 1 + 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/generated/docs/ApiToken.md b/generated/docs/ApiToken.md index 59d9e59d..7cfec464 100644 --- a/generated/docs/ApiToken.md +++ b/generated/docs/ApiToken.md @@ -7,7 +7,7 @@ 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] +**last_used_at** | **datetime, none_type** | The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used. | [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] diff --git a/generated/docs/ApiTokenCreateResponse.md b/generated/docs/ApiTokenCreateResponse.md index 61e92d66..a0308e20 100644 --- a/generated/docs/ApiTokenCreateResponse.md +++ b/generated/docs/ApiTokenCreateResponse.md @@ -8,7 +8,7 @@ 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] +**last_used_at** | **datetime, none_type** | The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used. | [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] diff --git a/generated/groundlight_openapi_client/model/api_token.py b/generated/groundlight_openapi_client/model/api_token.py index a77ec253..c81cecff 100644 --- a/generated/groundlight_openapi_client/model/api_token.py +++ b/generated/groundlight_openapi_client/model/api_token.py @@ -95,7 +95,10 @@ def openapi_types(): "name": (str,), # noqa: E501 "raw_key_snippet": (str,), # noqa: E501 "created_at": (datetime,), # noqa: E501 - "last_used_at": (datetime,), # noqa: E501 + "last_used_at": ( + datetime, + none_type, + ), # noqa: E501 "expires_at": ( datetime, none_type, diff --git a/generated/groundlight_openapi_client/model/api_token_create_response.py b/generated/groundlight_openapi_client/model/api_token_create_response.py index 1857d532..cc51c9b7 100644 --- a/generated/groundlight_openapi_client/model/api_token_create_response.py +++ b/generated/groundlight_openapi_client/model/api_token_create_response.py @@ -95,7 +95,10 @@ def openapi_types(): "name": (str,), # noqa: E501 "raw_key_snippet": (str,), # noqa: E501 "created_at": (datetime,), # noqa: E501 - "last_used_at": (datetime,), # noqa: E501 + "last_used_at": ( + datetime, + none_type, + ), # noqa: E501 "raw_key": (str,), # noqa: E501 "expires_at": ( datetime, diff --git a/generated/model.py b/generated/model.py index 7c8f5bba..6a5cff31 100644 --- a/generated/model.py +++ b/generated/model.py @@ -36,8 +36,9 @@ class ApiToken(BaseModel): ..., 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)." + last_used_at: Optional[datetime] = Field( + ..., + description="The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used.", ) expires_at: Optional[datetime] = Field( None, description="When does this token expire? If Null, the token never expires." @@ -57,8 +58,9 @@ class ApiTokenCreateResponse(BaseModel): ..., 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)." + last_used_at: Optional[datetime] = Field( + ..., + description="The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used.", ) expires_at: Optional[datetime] = Field( None, description="When does this token expire? If Null, the token never expires." diff --git a/spec/public-api.yaml b/spec/public-api.yaml index 932887ad..47f523ac 100644 --- a/spec/public-api.yaml +++ b/spec/public-api.yaml @@ -1132,9 +1132,10 @@ components: last_used_at: type: string format: date-time + nullable: true readOnly: true description: The most recent time this API token was used. (Helpful for - detecting suspicious activity). + detecting suspicious activity). Null if the token has never been used. expires_at: type: string format: date-time @@ -1169,9 +1170,10 @@ components: last_used_at: type: string format: date-time + nullable: true readOnly: true description: The most recent time this API token was used. (Helpful for - detecting suspicious activity). + detecting suspicious activity). Null if the token has never been used. expires_at: type: string format: date-time diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 339f5fef..01e8daf9 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -399,6 +399,7 @@ def _mint_replacement( _request_timeout=self._request_timeout, ) current = self._current_from_response(response, minted_at) + next_previous: Optional[PreviousToken] if previous is not None: next_previous = previous elif slot and record_replaced_current: From 59782e04078090f8992f8406ed7ec429c268ed68 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 19:17:31 +0000 Subject: [PATCH 13/30] Stop token refresh during HTTP retry tests Short testing refresh intervals raced with urllib3 mocks and inflated call counts. Close the configure-time probe client and pause refresh on the retry-test fixture. Co-authored-by: Cursor --- test/conftest.py | 13 +++++++++---- test/unit/test_http_retries.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index a3160457..1588b696 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -21,10 +21,15 @@ def fixture_detector_name() -> Callable[..., str]: def pytest_configure(config): # pylint: disable=unused-argument # Run environment check before tests gl = Groundlight() - if gl._user_is_privileged(): # pylint: disable=protected-access - raise RuntimeError( - "ERROR: You are running tests with a privileged user. Please run tests with a non-privileged user." - ) + try: + if gl._user_is_privileged(): # pylint: disable=protected-access + raise RuntimeError( + "ERROR: You are running tests with a privileged user. Please run tests with a non-privileged user." + ) + finally: + # Always close so the token-refresh thread from this probe client does not outlive + # configure and race with later tests that mock the HTTP transport. + gl.close() @pytest.fixture(name="gl") diff --git a/test/unit/test_http_retries.py b/test/unit/test_http_retries.py index 04b75393..2ff5f161 100644 --- a/test/unit/test_http_retries.py +++ b/test/unit/test_http_retries.py @@ -15,9 +15,15 @@ @pytest.fixture(name="gl") def groundlight_fixture() -> Groundlight: - "Creates a Groundlight client" + """Create a Groundlight client with token refresh stopped. + + These tests patch urllib3/requests globally and assert exact call counts. A live + refresh thread (especially with short testing intervals) can inflate those counts. + """ gl = Groundlight() - return gl + gl._token_manager.close() # pylint: disable=protected-access + yield gl + gl.api_client.close() @pytest.fixture(name="detector") From 0bc4b13ddb21125575d56e4f07804bbe86ed7e61 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 19:29:42 +0000 Subject: [PATCH 14/30] Disable token refresh during the test suite Short testing refresh intervals left orphaned refresh threads that raced urllib3 mocks in HTTP retry tests. Default tests to GROUNDLIGHT_DISABLE_TOKEN_REFRESH=1 and close client fixtures. Co-authored-by: Cursor --- src/groundlight/client.py | 17 +++++++++++++++-- src/groundlight/config.py | 9 ++++++++- test/conftest.py | 25 ++++++++++++++++++------- test/unit/test_http_retries.py | 17 +++++++---------- test/unit/test_token_refresh_client.py | 3 ++- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 7828234b..7e192682 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -41,7 +41,12 @@ from urllib3.util.retry import Retry from groundlight.binary_labels import Label, convert_internal_label_to_display -from groundlight.config import API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME +from groundlight.config import ( + API_TOKEN_MISSING_HELP_MESSAGE, + API_TOKEN_VARIABLE_NAME, + DISABLE_TLS_VARIABLE_NAME, + DISABLE_TOKEN_REFRESH_VARIABLE_NAME, +) from groundlight.encodings import url_encode_dict from groundlight.images import ByteStreamWrapper, parse_supported_image_types, shrink_image_if_needed from groundlight.internalapi import ( @@ -212,7 +217,15 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() - self._token_manager.start() + # Tests set GROUNDLIGHT_DISABLE_TOKEN_REFRESH so short testing refresh intervals do not + # race with suites that mock the HTTP transport and assert exact call counts. + disable_refresh = os.environ.get(DISABLE_TOKEN_REFRESH_VARIABLE_NAME, "").lower() in { + "1", + "true", + "yes", + } + if not disable_refresh: + self._token_manager.start() def __repr__(self) -> str: """Return a concise description of the connected client.""" diff --git a/src/groundlight/config.py b/src/groundlight/config.py index c254d46d..99fe8ded 100644 --- a/src/groundlight/config.py +++ b/src/groundlight/config.py @@ -3,9 +3,16 @@ DEFAULT_ENDPOINT = "https://api.groundlight.ai/" DISABLE_TLS_VARIABLE_NAME = "DISABLE_TLS_VERIFY" +DISABLE_TOKEN_REFRESH_VARIABLE_NAME = "GROUNDLIGHT_DISABLE_TOKEN_REFRESH" -__all__ = ["API_TOKEN_WEB_URL", "API_TOKEN_VARIABLE_NAME", "DEFAULT_ENDPOINT", "DISABLE_TLS_VARIABLE_NAME"] +__all__ = [ + "API_TOKEN_WEB_URL", + "API_TOKEN_VARIABLE_NAME", + "DEFAULT_ENDPOINT", + "DISABLE_TLS_VARIABLE_NAME", + "DISABLE_TOKEN_REFRESH_VARIABLE_NAME", +] API_TOKEN_MISSING_HELP_MESSAGE = ( "No API token found. Please put your token in an environment variable " diff --git a/test/conftest.py b/test/conftest.py index 1588b696..330926c3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,17 @@ +import os from datetime import datetime -from typing import Callable +from typing import Callable, Iterator from uuid import uuid4 import pytest from groundlight import ExperimentalApi, Groundlight +from groundlight.config import DISABLE_TOKEN_REFRESH_VARIABLE_NAME from model import Detector, ImageQuery, ImageQueryTypeEnum, ResultTypeEnum +# Keep background token refresh off for the test suite. Short testing TTL/refresh intervals +# otherwise race with tests that mock urllib3/requests and assert exact call counts. +os.environ[DISABLE_TOKEN_REFRESH_VARIABLE_NAME] = "1" + def _generate_unique_detector_name(prefix: str = "Test") -> str: """Generates a detector name with a timestamp and random suffix to ensure uniqueness.""" @@ -27,17 +33,18 @@ def pytest_configure(config): # pylint: disable=unused-argument "ERROR: You are running tests with a privileged user. Please run tests with a non-privileged user." ) finally: - # Always close so the token-refresh thread from this probe client does not outlive - # configure and race with later tests that mock the HTTP transport. gl.close() @pytest.fixture(name="gl") -def fixture_gl() -> Groundlight: +def fixture_gl() -> Iterator[Groundlight]: """Creates a Groundlight client object for testing.""" _gl = Groundlight() _gl.DEFAULT_WAIT = 10 - return _gl + try: + yield _gl + finally: + _gl.close() @pytest.fixture(name="detector") @@ -87,10 +94,14 @@ def fixture_image_query_zero(gl_experimental: Groundlight, count_detector: Detec @pytest.fixture(name="gl_experimental") -def fixture_gl_experimental() -> ExperimentalApi: +def fixture_gl_experimental() -> Iterator[ExperimentalApi]: + """Creates an ExperimentalApi client object for testing.""" _gl = ExperimentalApi() _gl.DEFAULT_WAIT = 10 - return _gl + try: + yield _gl + finally: + _gl.close() @pytest.fixture(name="initial_iq") diff --git a/test/unit/test_http_retries.py b/test/unit/test_http_retries.py index 2ff5f161..8b4c0c74 100644 --- a/test/unit/test_http_retries.py +++ b/test/unit/test_http_retries.py @@ -1,4 +1,4 @@ -from typing import Any, Callable +from typing import Any, Callable, Iterator from unittest import mock import pytest @@ -14,16 +14,13 @@ @pytest.fixture(name="gl") -def groundlight_fixture() -> Groundlight: - """Create a Groundlight client with token refresh stopped. - - These tests patch urllib3/requests globally and assert exact call counts. A live - refresh thread (especially with short testing intervals) can inflate those counts. - """ +def groundlight_fixture() -> Iterator[Groundlight]: + """Create a Groundlight client for HTTP retry tests.""" gl = Groundlight() - gl._token_manager.close() # pylint: disable=protected-access - yield gl - gl.api_client.close() + try: + yield gl + finally: + gl.close() @pytest.fixture(name="detector") diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index b282841f..8277d5bc 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -11,8 +11,9 @@ EXPECTED_CALL_COUNT = 2 -def test_groundlight_starts_and_closes_token_manager(mocker): +def test_groundlight_starts_and_closes_token_manager(mocker, monkeypatch): """Groundlight owns the token manager lifecycle and supports context management.""" + monkeypatch.delenv("GROUNDLIGHT_DISABLE_TOKEN_REFRESH", raising=False) manager = Mock() token_manager_class = mocker.patch("groundlight.client.TokenManager", return_value=manager) mocker.patch.object(Groundlight, "_verify_connectivity") From b00991df7f9a4609addb8a33d9e6f73e6f9a65fe Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 21:19:38 +0000 Subject: [PATCH 15/30] Simplify token rotation to use-or-rotate under the lock Rotate whenever the current token is due: revoke previous, demote current, mint a replacement. Drop grace-blocked minting that could outrun short test TTLs, and clarify configured-token error messages. Co-authored-by: Cursor --- scripts/watch_token_rotation.py | 65 ++++++++++++++++++++++++++++++++ src/groundlight/token_manager.py | 47 +++++++++++++++-------- test/unit/test_token_manager.py | 31 +++++++-------- 3 files changed, 110 insertions(+), 33 deletions(-) create mode 100644 scripts/watch_token_rotation.py diff --git a/scripts/watch_token_rotation.py b/scripts/watch_token_rotation.py new file mode 100644 index 00000000..5bad6283 --- /dev/null +++ b/scripts/watch_token_rotation.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Poll whoami against api.dev and print the live token cache for rotation testing. + +Requires GROUNDLIGHT_API_TOKEN for a token that can mint on the target environment. + +Example: + poetry run python scripts/watch_token_rotation.py +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +from groundlight import Groundlight +from groundlight.config import DISABLE_TOKEN_REFRESH_VARIABLE_NAME + +DEV_ENDPOINT = "https://api.dev.groundlight.ai/" +POLL_SECONDS = 1 + + +def _active_token(gl: Groundlight) -> str: + """Return the API token currently configured on the client.""" + return gl.configuration.api_key["ApiToken"] + + +def _slot_path(gl: Groundlight) -> Path: + """Return the on-disk token cache path for this client's bootstrap snippet.""" + return gl._token_manager._slot_path # pylint: disable=protected-access + + +def main() -> None: + """Create a client and print whoami + token cache every few seconds.""" + # Ensure background refresh is on even if a prior test session exported the disable flag. + os.environ.pop(DISABLE_TOKEN_REFRESH_VARIABLE_NAME, None) + + if not os.environ.get("GROUNDLIGHT_API_TOKEN"): + raise SystemExit("Set GROUNDLIGHT_API_TOKEN before running this script.") + + with Groundlight(endpoint=DEV_ENDPOINT) as gl: + slot_path = _slot_path(gl) + print(f"endpoint={gl.endpoint}") + print(f"slot_path={slot_path}") + print(f"polling whoami every {POLL_SECONDS}s (Ctrl-C to stop)\n") + + while True: + user = gl.whoami() + token = _active_token(gl) + print("=" * 60) + print(time.time()) + print(f"whoami: {user}") + print(f"active token snippet: {token[:20]}") + print(f"cache file: {slot_path}") + if slot_path.exists(): + print(json.dumps(json.loads(slot_path.read_text(encoding="utf-8")), indent=2)) + else: + print("(cache file missing)") + print() + time.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + main() diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 01e8daf9..befd504c 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -33,7 +33,6 @@ # - stop hardcoding TOKEN_TTL_DAYS / REFRESH_INTERVAL_DAYS TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): testing only (3 minutes); revert to 30 REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): testing only (1 minute); revert to 1 -CLEANUP_GRACE_FACTOR = 2 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 LOCK_TIMEOUT_SECONDS = 60 @@ -41,6 +40,8 @@ # TTL, so retry on a short cadence to recover quickly from a transient outage rather than # waiting a full refresh interval (which would also spin when the token is already overdue). REFRESH_RETRY_BACKOFF_SECONDS = 5 * 60 +# Previous-token grace equals one refresh interval by construction: we only rotate when +# current is at least REFRESH_INTERVAL old, and previous was demoted at that mint time. # Matches the trailing " xxxxxx" hex suffix this class appends, so repeated rotations # reuse a stable base name instead of accreting a new suffix each cycle. TOKEN_NAME_SUFFIX_PATTERN = re.compile(r" [0-9a-f]{6}$") @@ -175,7 +176,10 @@ def __init__( if len(self._bootstrap_snippet) != TOKEN_SNIPPET_LENGTH or not re.fullmatch( r"[A-Za-z0-9_]+", self._bootstrap_snippet ): - raise TokenManagerError("The bootstrap API token has an invalid format") + raise TokenManagerError( + "The configured API token has an invalid format. " + "Check that GROUNDLIGHT_API_TOKEN is set correctly." + ) self._configuration = configuration self._request_timeout = request_timeout self._token_dir = token_dir or self._default_token_dir() @@ -252,7 +256,10 @@ def _initialize_token(self) -> None: except TokenManagerError: raise except Exception as exc: - raise TokenManagerError("Unable to mint a working API token with the bootstrap token") from exc + raise TokenManagerError( + "Unable to create a working API token. " + "Check that GROUNDLIGHT_API_TOKEN is set to a valid token for this endpoint." + ) from exc def start(self) -> None: """Start background refresh when the server supports token management.""" @@ -300,7 +307,12 @@ def recover_from_unauthorized(self) -> None: raise TokenManagerError("The cached token was rejected and could not be replaced") from exc def refresh(self) -> bool: - """Refresh the working token, returning whether the cycle completed successfully.""" + """Use the cached token if it is still fresh; otherwise rotate under the file lock. + + Rotation always completes the two-token cycle: revoke previous (best effort), demote + current to previous, and mint a new current. Returns False only when rotation could + not run (lock timeout, mint failure, or token API unavailable). + """ try: with self._lock: slot = self._load_slot() @@ -313,12 +325,13 @@ def refresh(self) -> bool: self._activate(slot.current) if _utc_now() - slot.current.minted_at < timedelta(days=REFRESH_INTERVAL_DAYS): return True - if not self._cleanup_previous(slot.previous): - return False - # Read base_name from slot; fall back to a live lookup for old-format slots. + # Due for rotation: previous has been demoted for at least one refresh interval. + self._revoke_previous(slot.previous) base_name = slot.base_name or self._resolve_base_name(slot.current.snippet) self._mint_replacement(base_name=base_name, slot=slot) return True + except TokenManagerError: + raise except NotFoundException: logger.warning( "Automatic API token refresh is unavailable because this server does not support token management" @@ -329,6 +342,9 @@ def refresh(self) -> bool: except FileLockTimeout: logger.warning("Skipping token refresh because the cache lock could not be acquired") return False + except Exception: + logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) + return False def _run(self) -> None: """Refresh tokens on schedule until the client is closed.""" @@ -426,21 +442,20 @@ def _get_token_by_snippet(self, snippet: str) -> ApiToken: """Retrieve token metadata by snippet via the dedicated API endpoint.""" return self._api_tokens.get_api_token_by_snippet(snippet, _request_timeout=self._request_timeout) - def _cleanup_previous(self, previous: Optional[PreviousToken]) -> bool: - """Delete due token metadata, returning whether it is safe to replace the slot.""" + def _revoke_previous(self, previous: Optional[PreviousToken]) -> None: + """Best-effort revoke of the demoted previous token before it is replaced in the slot.""" if previous is None: - return True - grace_period = timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS) - if _utc_now() - previous.minted_at < grace_period: - return False + return try: self._api_tokens.delete_api_token(previous.name, _request_timeout=self._request_timeout) except NotFoundException: logger.debug("Previous API token '%s' was already deleted", previous.name) except ApiException: - logger.warning("Unable to delete previous API token '%s'", previous.name, exc_info=True) - return False - return True + logger.warning( + "Unable to delete previous API token '%s'; continuing rotation", + previous.name, + exc_info=True, + ) @staticmethod def _new_token_name(base_name: str) -> str: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 3d5aea0b..184b9acb 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -8,7 +8,6 @@ import pytest from groundlight import token_manager from groundlight.token_manager import ( - CLEANUP_GRACE_FACTOR, REFRESH_INTERVAL_DAYS, REFRESH_RETRY_BACKOFF_SECONDS, TOKEN_NAME_MAX_LENGTH, @@ -137,7 +136,7 @@ def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path) def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): - """Scheduled refresh revokes an old previous token and records the replaced current token.""" + """When refresh is due, revoke previous, demote current, and mint a replacement.""" api = Mock() api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) @@ -146,7 +145,7 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", - "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), + "minted_at": NOW.isoformat(), } manager._slot_path.write_text(json.dumps(old_slot)) later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) @@ -164,8 +163,8 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): assert cached["previous"]["name"] == "Device token abc123" -def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path): - """A failed cleanup postpones minting so the deletion can be retried later.""" +def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path): + """A failed previous revoke does not block minting the next working token.""" api = Mock() api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) @@ -173,20 +172,23 @@ def test_refresh_preserves_cleanup_metadata_when_deletion_fails(mocker, tmp_path slot = json.loads(manager._slot_path.read_text()) slot["previous"] = { "name": "older token", - "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), + "minted_at": NOW.isoformat(), } manager._slot_path.write_text(json.dumps(slot)) - mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) + later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=later) api.reset_mock() api.delete_api_token.side_effect = ApiException(status=500) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) refresh_succeeded = manager.refresh() - assert not refresh_succeeded - api.create_api_token.assert_not_called() + assert refresh_succeeded + api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) + api.create_api_token.assert_called_once() cached = json.loads(manager._slot_path.read_text()) - assert cached["previous"]["name"] == "older token" - assert cached["current"]["raw_key"] == "api_working_token_one" + assert cached["current"]["raw_key"] == "api_working_token_two" + assert cached["previous"]["name"] == "Device token abc123" def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): @@ -308,7 +310,7 @@ def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): """Invalid token snippets are rejected before cache paths are created.""" configuration = Configuration(host="https://example.com/device-api") - with pytest.raises(TokenManagerError, match="invalid format"): + with pytest.raises(TokenManagerError, match="configured API token has an invalid format"): TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) @@ -321,13 +323,8 @@ def test_refresh_falls_back_to_snippet_endpoint_for_old_format_slot(mocker, tmp_ api.reset_mock() # Simulate a slot written by an older SDK version (no base_name field). - # Age previous past cleanup grace so refresh can mint a replacement. slot_data = json.loads(manager._slot_path.read_text()) del slot_data["base_name"] - slot_data["previous"] = { - "name": "Device token", - "minted_at": (NOW - timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS, seconds=1)).isoformat(), - } manager._slot_path.write_text(json.dumps(slot_data)) later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) From 2218fb6993f7591c8c07dc9c2df5fdb64e0afe1b Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 23 Jul 2026 21:20:15 +0000 Subject: [PATCH 16/30] Automatically reformatting code --- src/groundlight/token_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index befd504c..7ffbc602 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -177,8 +177,7 @@ def __init__( r"[A-Za-z0-9_]+", self._bootstrap_snippet ): raise TokenManagerError( - "The configured API token has an invalid format. " - "Check that GROUNDLIGHT_API_TOKEN is set correctly." + "The configured API token has an invalid format. Check that GROUNDLIGHT_API_TOKEN is set correctly." ) self._configuration = configuration self._request_timeout = request_timeout From 803b3b6f0aefcf934a4935f1e313a78c5b12b8f9 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 21:20:30 +0000 Subject: [PATCH 17/30] Remove local token rotation watch script from the PR Keep the helper for local testing only; it does not belong in the branch under review. Co-authored-by: Cursor --- scripts/watch_token_rotation.py | 65 --------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 scripts/watch_token_rotation.py diff --git a/scripts/watch_token_rotation.py b/scripts/watch_token_rotation.py deleted file mode 100644 index 5bad6283..00000000 --- a/scripts/watch_token_rotation.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Poll whoami against api.dev and print the live token cache for rotation testing. - -Requires GROUNDLIGHT_API_TOKEN for a token that can mint on the target environment. - -Example: - poetry run python scripts/watch_token_rotation.py -""" - -from __future__ import annotations - -import json -import os -import time -from pathlib import Path - -from groundlight import Groundlight -from groundlight.config import DISABLE_TOKEN_REFRESH_VARIABLE_NAME - -DEV_ENDPOINT = "https://api.dev.groundlight.ai/" -POLL_SECONDS = 1 - - -def _active_token(gl: Groundlight) -> str: - """Return the API token currently configured on the client.""" - return gl.configuration.api_key["ApiToken"] - - -def _slot_path(gl: Groundlight) -> Path: - """Return the on-disk token cache path for this client's bootstrap snippet.""" - return gl._token_manager._slot_path # pylint: disable=protected-access - - -def main() -> None: - """Create a client and print whoami + token cache every few seconds.""" - # Ensure background refresh is on even if a prior test session exported the disable flag. - os.environ.pop(DISABLE_TOKEN_REFRESH_VARIABLE_NAME, None) - - if not os.environ.get("GROUNDLIGHT_API_TOKEN"): - raise SystemExit("Set GROUNDLIGHT_API_TOKEN before running this script.") - - with Groundlight(endpoint=DEV_ENDPOINT) as gl: - slot_path = _slot_path(gl) - print(f"endpoint={gl.endpoint}") - print(f"slot_path={slot_path}") - print(f"polling whoami every {POLL_SECONDS}s (Ctrl-C to stop)\n") - - while True: - user = gl.whoami() - token = _active_token(gl) - print("=" * 60) - print(time.time()) - print(f"whoami: {user}") - print(f"active token snippet: {token[:20]}") - print(f"cache file: {slot_path}") - if slot_path.exists(): - print(json.dumps(json.loads(slot_path.read_text(encoding="utf-8")), indent=2)) - else: - print("(cache file missing)") - print() - time.sleep(POLL_SECONDS) - - -if __name__ == "__main__": - main() From d5b9f67d87fad9ff4faa911557be6d2515b1960f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 23 Jul 2026 23:21:43 +0000 Subject: [PATCH 18/30] Surface 401 response bodies in token init and recovery Pass the server's unauthorized detail through to ApiTokenError so expired or revoked tokens are reported clearly, and silence the known broad-exception catches that pylint flags in TokenManager. Co-authored-by: Cursor --- src/groundlight/internalapi.py | 22 ++++++++++++++++++---- src/groundlight/token_manager.py | 24 ++++++++++++------------ test/unit/test_token_manager.py | 24 ++++++++++++++++++++---- test/unit/test_token_refresh_client.py | 4 ++-- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index aaa553e5..a604f0af 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -26,6 +26,19 @@ class NotFoundError(Exception): pass +def api_exception_detail(exc: ApiException) -> Optional[str]: + """Return a stripped response body from an API exception, if present.""" + body = exc.body + if body is None: + return None + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace") + else: + text = str(body) + text = text.strip() + return text or None + + def sanitize_endpoint_url(endpoint: Optional[str] = None) -> str: """Takes a URL for an endpoint, and returns a "sanitized" version of it. Currently the production API path must be exactly "/device-api". @@ -170,11 +183,11 @@ def __init__(self, *args, **kwargs): """Initialize the generated API client with SDK-specific behavior.""" super().__init__(*args, **kwargs) self.user_agent = f"Groundlight-Python-SDK/{get_version()}/{platform.platform()}/{platform.python_version()}" - self._unauthorized_handler: Optional[Callable[[], None]] = None + self._unauthorized_handler: Optional[Callable[[Optional[str]], None]] = None REQUEST_ID_HEADER = "X-Request-Id" - def set_unauthorized_handler(self, handler: Callable[[], None]) -> None: + def set_unauthorized_handler(self, handler: Callable[[Optional[str]], None]) -> None: """Set the callback used to recover and retry after a 401 response.""" self._unauthorized_handler = handler @@ -220,7 +233,7 @@ def call_api(self, *args, **kwargs): except ApiException as exc: if exc.status != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: raise - self._unauthorized_handler() + self._unauthorized_handler(api_exception_detail(exc)) retry_args = list(args) retry_kwargs = dict(kwargs) if replayable_body is not None: @@ -235,7 +248,8 @@ def request_with_unauthorized_recovery(self, method: str, url: str, **kwargs) -> response = requests.request(method, url, **kwargs) if response.status_code != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: return response - self._unauthorized_handler() + detail = (response.text or "").strip() or None + self._unauthorized_handler(detail) headers = dict(kwargs.get("headers", {})) headers["x-api-token"] = self.configuration.api_key["ApiToken"] kwargs["headers"] = headers diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 7ffbc602..04ce28b0 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -14,13 +14,13 @@ from filelock import Timeout as FileLockTimeout from groundlight_openapi_client import Configuration from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi -from groundlight_openapi_client.exceptions import ApiException, NotFoundException +from groundlight_openapi_client.exceptions import ApiException, NotFoundException, UnauthorizedException 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 platformdirs import user_data_path -from groundlight.internalapi import GroundlightApiClient +from groundlight.internalapi import GroundlightApiClient, api_exception_detail logger = logging.getLogger("groundlight.sdk") @@ -252,9 +252,12 @@ def _initialize_token(self) -> None: ) self._available = False self._set_api_token(self._bootstrap_token) + except UnauthorizedException as exc: + detail = api_exception_detail(exc) or "API token was rejected" + raise TokenManagerError(detail) from exc except TokenManagerError: raise - except Exception as exc: + except Exception as exc: # pylint: disable=broad-exception-caught raise TokenManagerError( "Unable to create a working API token. " "Check that GROUNDLIGHT_API_TOKEN is set to a valid token for this endpoint." @@ -278,31 +281,28 @@ def close(self) -> None: self._thread.join() self._rotation_client.close() - def recover_from_unauthorized(self) -> None: + def recover_from_unauthorized(self, detail: Optional[str] = None) -> None: """Recover from a 401 by loading a fresher cached token written by another process. Does not remint from the configured bootstrap token. If no fresher token is available - on disk, the working token chain is broken and requires human intervention - (provision a new GROUNDLIGHT_API_TOKEN). + on disk, raise using the server's 401 detail when provided. """ if not self._available: raise TokenManagerError("Automatic token recovery is unavailable on this server") failed_token = self._configuration.api_key["ApiToken"] + rejection = (detail or "").strip() or "API token was rejected" try: with self._lock: slot = self._load_slot() if slot and slot.current.raw_key != failed_token and slot.current.expires_at > _utc_now(): self._activate(slot.current) return - raise TokenManagerError( - "The working API token was rejected and no fresher cached token is available. " - "Please provision a new GROUNDLIGHT_API_TOKEN." - ) + raise TokenManagerError(rejection) except FileLockTimeout as exc: raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc except TokenManagerError: raise - except Exception as exc: + except Exception as exc: # pylint: disable=broad-exception-caught raise TokenManagerError("The cached token was rejected and could not be replaced") from exc def refresh(self) -> bool: @@ -341,7 +341,7 @@ def refresh(self) -> bool: except FileLockTimeout: logger.warning("Skipping token refresh because the cache lock could not be acquired") return False - except Exception: + except Exception: # pylint: disable=broad-exception-caught logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) return False diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 184b9acb..46fbdfeb 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -16,7 +16,7 @@ TokenManagerError, ) from groundlight_openapi_client import Configuration -from groundlight_openapi_client.exceptions import ApiException, NotFoundException +from groundlight_openapi_client.exceptions import ApiException, NotFoundException, UnauthorizedException BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) @@ -244,6 +244,22 @@ def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): api.create_api_token.assert_not_called() +def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): + """A 401 during bootstrap lookup raises with the server's response body.""" + api = Mock() + api.get_api_token_by_snippet.side_effect = UnauthorizedException( + http_resp=SimpleNamespace( + status=401, + reason="Unauthorized", + data="The API token has expired", + getheaders=lambda: {}, + ) + ) + + with pytest.raises(TokenManagerError, match="The API token has expired"): + _manager(mocker, tmp_path, api) + + def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): """A rejected cached token raises loudly when no fresher token is on disk.""" api = Mock() @@ -251,8 +267,8 @@ def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tm api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - with pytest.raises(TokenManagerError, match="provision a new GROUNDLIGHT_API_TOKEN"): - manager.recover_from_unauthorized() + with pytest.raises(TokenManagerError, match="API identity has been revoked"): + manager.recover_from_unauthorized("API identity has been revoked") api.create_api_token.assert_called_once() # only during init, not during recovery @@ -300,7 +316,7 @@ def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - with pytest.raises(TokenManagerError, match="provision a new GROUNDLIGHT_API_TOKEN"): + with pytest.raises(TokenManagerError, match="API token was rejected"): manager.recover_from_unauthorized() assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index 8277d5bc..0988ad4c 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -40,7 +40,7 @@ def test_api_client_recovers_and_retries_once_after_unauthorized(mocker): result = client.call_api("/v1/test", "GET", {}, [], {}) assert result == "success" - handler.assert_called_once_with() + handler.assert_called_once_with(None) assert parent_call.call_count == EXPECTED_CALL_COUNT @@ -75,7 +75,7 @@ def test_raw_request_recovers_and_retries_once_after_unauthorized(mocker): configuration.api_key["ApiToken"] = "old-token" client = GroundlightApiClient(configuration) - def refresh_token() -> None: + def refresh_token(_detail=None) -> None: """Simulate replacing the rejected token.""" configuration.api_key["ApiToken"] = "new-token" From 6dbd1be27f3c675ba7d3a07624222c31dd833030 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 17:19:09 +0000 Subject: [PATCH 19/30] Discover rotation from token expires_at instead of hardcoded TTLs Never-expire configured tokens skip minting and refresh entirely. Expiring tokens mint without a client TTL and refresh every observed_ttl/30 from the working token's lifetime. Co-authored-by: Cursor --- src/groundlight/client.py | 4 +- src/groundlight/token_manager.py | 105 ++++++++++++++--------- test/unit/test_token_manager.py | 140 +++++++++++++++++++++---------- 3 files changed, 165 insertions(+), 84 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 7e192682..f8f615fc 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -217,8 +217,8 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() - # Tests set GROUNDLIGHT_DISABLE_TOKEN_REFRESH so short testing refresh intervals do not - # race with suites that mock the HTTP transport and assert exact call counts. + # Tests set GROUNDLIGHT_DISABLE_TOKEN_REFRESH so background refresh does not race with + # suites that mock the HTTP transport and assert exact call counts. disable_refresh = os.environ.get(DISABLE_TOKEN_REFRESH_VARIABLE_NAME, "").lower() in { "1", "true", diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 04ce28b0..bd3db520 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -25,14 +25,9 @@ logger = logging.getLogger("groundlight.sdk") TOKEN_SNIPPET_LENGTH = 20 -# TODO(GL-1709): TEMPORARY short-lived values for live rotation testing only. -# Before merge / production: set TOKEN_TTL_DAYS = 30 and REFRESH_INTERVAL_DAYS = 1. -# Follow-up (after zuuul exposes identity token_ttl on public /v1/api-tokens): -# - discover rotate-vs-not from by-snippet token_ttl (null => no refresh thread) -# - derive refresh_interval from mint expires_at as observed_ttl / 30 -# - stop hardcoding TOKEN_TTL_DAYS / REFRESH_INTERVAL_DAYS -TOKEN_TTL_DAYS = 3 / (24 * 60) # TODO(GL-1709): testing only (3 minutes); revert to 30 -REFRESH_INTERVAL_DAYS = 1 / (24 * 60) # TODO(GL-1709): testing only (1 minute); revert to 1 +# Refresh fires after this fraction of the working token's observed lifetime +# (expires_at - minted_at). Example: 30-day TTL => refresh every 1 day. +REFRESH_INTERVAL_FRACTION = 1 / 30 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 LOCK_TIMEOUT_SECONDS = 60 @@ -41,7 +36,7 @@ # waiting a full refresh interval (which would also spin when the token is already overdue). REFRESH_RETRY_BACKOFF_SECONDS = 5 * 60 # Previous-token grace equals one refresh interval by construction: we only rotate when -# current is at least REFRESH_INTERVAL old, and previous was demoted at that mint time. +# current is at least one refresh interval old, and previous was demoted at that mint time. # Matches the trailing " xxxxxx" hex suffix this class appends, so repeated rotations # reuse a stable base name instead of accreting a new suffix each cycle. TOKEN_NAME_SUFFIX_PATTERN = re.compile(r" [0-9a-f]{6}$") @@ -83,27 +78,28 @@ class CurrentToken: raw_key: str snippet: str name: str - expires_at: datetime + expires_at: Optional[datetime] minted_at: datetime @classmethod def from_dict(cls, data: Dict[str, Any]) -> "CurrentToken": """Build a current token from its on-disk representation.""" + expires_raw = data.get("expires_at") return cls( raw_key=data["raw_key"], snippet=data["snippet"], name=data["name"], - expires_at=_parse_datetime(data["expires_at"]), + expires_at=_parse_datetime(expires_raw) if expires_raw is not None else None, minted_at=_parse_datetime(data["minted_at"]), ) - def to_dict(self) -> Dict[str, str]: + def to_dict(self) -> Dict[str, Optional[str]]: """Convert the current token to its JSON-compatible representation.""" return { "raw_key": self.raw_key, "snippet": self.snippet, "name": self.name, - "expires_at": _format_datetime(self.expires_at), + "expires_at": _format_datetime(self.expires_at) if self.expires_at is not None else None, "minted_at": _format_datetime(self.minted_at), } @@ -129,7 +125,7 @@ def to_dict(self) -> Dict[str, str]: class TokenSlot: """Represent the current and previous tokens stored in one cache slot. - base_name is the bootstrap token's human-readable name with any auto-generated suffix + base_name is the configured token's human-readable name with any auto-generated suffix stripped. It is established once at first mint and reused for all future rotations, so a token chain always looks like "My Sensor ab12cd", "My Sensor ef34gh", ... rather than accumulating nested suffixes. @@ -219,30 +215,38 @@ def _ensure_token_dir(self) -> None: raise TokenManagerError(f"Groundlight token directory '{self._token_dir}' is not writable") def _initialize_token(self) -> None: - """Load a valid cached token or mint one from the configured bootstrap token.""" + """Load a valid cached token, use a never-expire configured token, or mint a child.""" try: with self._lock: slot = self._load_slot() - if slot and slot.current.expires_at > _utc_now(): + if slot and self._is_usable_cached_token(slot.current): self._activate(slot.current) return + self._set_api_token(self._bootstrap_token) - # Look up the bootstrap token once: its name seeds base_name for every future - # rotation, and we park that name as previous for delayed cleanup (not immediate revoke). try: - bootstrap_meta = self._get_token_by_snippet(self._bootstrap_snippet) - base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", bootstrap_meta.name) - bootstrap_previous: Optional[PreviousToken] = PreviousToken( - name=bootstrap_meta.name, minted_at=_utc_now() - ) + configured_meta = self._get_token_by_snippet(self._bootstrap_snippet) except NotFoundException: - base_name = "sdk-auto" - bootstrap_previous = None + # by-snippet missing (old server) or unknown token: try minting with a + # generic base name; a subsequent 404 disables rotation. + self._mint_replacement( + base_name="sdk-auto", + slot=slot, + record_replaced_current=False, + previous=None, + ) + return + + if configured_meta.expires_at is None: + # Never-expire configured token: behave like pre-rotation Groundlight. + return + + base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", configured_meta.name) self._mint_replacement( base_name=base_name, slot=slot, record_replaced_current=False, - previous=bootstrap_previous, + previous=PreviousToken(name=configured_meta.name, minted_at=_utc_now()), ) except FileLockTimeout as exc: raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc @@ -263,10 +267,19 @@ def _initialize_token(self) -> None: "Check that GROUNDLIGHT_API_TOKEN is set to a valid token for this endpoint." ) from exc + @staticmethod + def _is_usable_cached_token(token: CurrentToken) -> bool: + """Whether a cached token can be activated without minting.""" + if token.expires_at is None: + return True + return token.expires_at > _utc_now() + def start(self) -> None: - """Start background refresh when the server supports token management.""" + """Start background refresh when the working token is set to expire.""" if not self._available or self._thread is not None: return + if self._current is None or self._current.expires_at is None: + return self._thread = threading.Thread( target=self._run, name=f"gl-token-refresh-{self._bootstrap_snippet[:8]}", @@ -284,8 +297,8 @@ def close(self) -> None: def recover_from_unauthorized(self, detail: Optional[str] = None) -> None: """Recover from a 401 by loading a fresher cached token written by another process. - Does not remint from the configured bootstrap token. If no fresher token is available - on disk, raise using the server's 401 detail when provided. + Does not remint from the configured token. If no fresher token is available on disk, + raise using the server's 401 detail when provided. """ if not self._available: raise TokenManagerError("Automatic token recovery is unavailable on this server") @@ -294,7 +307,11 @@ def recover_from_unauthorized(self, detail: Optional[str] = None) -> None: try: with self._lock: slot = self._load_slot() - if slot and slot.current.raw_key != failed_token and slot.current.expires_at > _utc_now(): + if ( + slot + and slot.current.raw_key != failed_token + and self._is_usable_cached_token(slot.current) + ): self._activate(slot.current) return raise TokenManagerError(rejection) @@ -322,7 +339,9 @@ def refresh(self) -> bool: ) self._activate(slot.current) - if _utc_now() - slot.current.minted_at < timedelta(days=REFRESH_INTERVAL_DAYS): + if slot.current.expires_at is None: + return True + if _utc_now() - slot.current.minted_at < self._refresh_interval(slot.current): return True # Due for rotation: previous has been demoted for at least one refresh interval. self._revoke_previous(slot.previous) @@ -349,11 +368,10 @@ def _run(self) -> None: """Refresh tokens on schedule until the client is closed.""" while not self._stop_event.is_set(): current = self._current - if current is None: - wait_seconds = 0.0 - else: - refresh_at = current.minted_at + timedelta(days=REFRESH_INTERVAL_DAYS) - wait_seconds = max(0.0, (refresh_at - _utc_now()).total_seconds()) + if current is None or current.expires_at is None: + return + refresh_at = current.minted_at + self._refresh_interval(current) + wait_seconds = max(0.0, (refresh_at - _utc_now()).total_seconds()) if self._stop_event.wait(wait_seconds): return try: @@ -364,6 +382,13 @@ def _run(self) -> None: if not refresh_succeeded and self._stop_event.wait(REFRESH_RETRY_BACKOFF_SECONDS): return + @staticmethod + def _refresh_interval(token: CurrentToken) -> timedelta: + """Return how long to keep a working token before rotating.""" + if token.expires_at is None: + raise TokenManagerError("Cannot compute a refresh interval for a never-expiring token") + return (token.expires_at - token.minted_at) * REFRESH_INTERVAL_FRACTION + def _load_slot(self) -> Optional[TokenSlot]: """Load the cache slot, returning None when it does not exist.""" if not self._slot_path.exists(): @@ -409,8 +434,9 @@ def _mint_replacement( """Mint a new token, persist the updated slot, and activate the new credential.""" new_name = self._new_token_name(base_name) minted_at = _utc_now() + # Omit expires_at so the server applies the identity's token lifetime policy. response = self._api_tokens.create_api_token( - ApiTokenRequest(name=new_name, expires_at=minted_at + timedelta(days=TOKEN_TTL_DAYS)), + ApiTokenRequest(name=new_name), _request_timeout=self._request_timeout, ) current = self._current_from_response(response, minted_at) @@ -466,10 +492,9 @@ def _new_token_name(base_name: str) -> str: @staticmethod def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: """Convert a token creation response into cached current-token data.""" + expires_at: Optional[datetime] if response.expires_at is None: - # TODO(GL-1709): null expires_at means a never-expire identity. Once token_ttl - # discovery lands, skip the refresh loop instead of inventing a client-side expiry. - expires_at = minted_at + timedelta(days=TOKEN_TTL_DAYS) + expires_at = None else: expires_at = _normalize_datetime(response.expires_at) return CurrentToken( diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 46fbdfeb..3ad44190 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -8,10 +8,9 @@ import pytest from groundlight import token_manager from groundlight.token_manager import ( - REFRESH_INTERVAL_DAYS, + REFRESH_INTERVAL_FRACTION, REFRESH_RETRY_BACKOFF_SECONDS, TOKEN_NAME_MAX_LENGTH, - TOKEN_TTL_DAYS, TokenManager, TokenManagerError, ) @@ -20,22 +19,30 @@ BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) +TOKEN_TTL = timedelta(days=30) +REFRESH_INTERVAL = TOKEN_TTL * REFRESH_INTERVAL_FRACTION TOKEN_CACHE_MODE = 0o600 TOKEN_DIR_MODE = 0o700 -def _metadata(name: str, raw_key: str) -> SimpleNamespace: - """Build the token metadata returned by snippet or list operations.""" - return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20]) +def _expiring_metadata(name: str, raw_key: str) -> SimpleNamespace: + """Build by-snippet metadata for an expiring configured token.""" + return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20], expires_at=NOW + TOKEN_TTL) -def _created_token(name: str, raw_key: str, now: datetime) -> SimpleNamespace: - """Build a token creation response.""" +def _never_expire_metadata(name: str, raw_key: str) -> SimpleNamespace: + """Build by-snippet metadata for a never-expire configured token.""" + return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20], expires_at=None) + + +def _created_token(name: str, raw_key: str, now: datetime, *, expires_at=...) -> SimpleNamespace: + """Build a token creation response. Pass expires_at=None for a never-expire child.""" + resolved_expires_at = now + TOKEN_TTL if expires_at is ... else expires_at return SimpleNamespace( name=name, raw_key=raw_key, raw_key_snippet=raw_key[:20], - expires_at=now + timedelta(days=TOKEN_TTL_DAYS), + expires_at=resolved_expires_at, ) @@ -54,16 +61,16 @@ def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): - """A missing slot is minted from the bootstrap token and stored with mode 0600.""" + """An expiring configured token mints a child and stores it with mode 0600.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) request = api.create_api_token.call_args.args[0] assert request.name.startswith("Device token ") - assert request.expires_at == NOW + timedelta(days=TOKEN_TTL_DAYS) + assert "expires_at" not in request._data_store # server-authoritative mint; no client TTL assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" assert stat.S_IMODE(manager._slot_path.stat().st_mode) == TOKEN_CACHE_MODE cached = json.loads(manager._slot_path.read_text()) @@ -73,10 +80,10 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): api.delete_api_token.assert_not_called() -def test_initialization_parks_bootstrap_token_as_previous(mocker, tmp_path): - """After minting the first working token, the bootstrap token is parked as previous (not revoked).""" +def test_initialization_parks_configured_token_as_previous(mocker, tmp_path): + """After minting the first working token, the configured token is parked as previous.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -87,10 +94,44 @@ def test_initialization_parks_bootstrap_token_as_previous(mocker, tmp_path): assert cached["previous"]["minted_at"] == NOW.isoformat().replace("+00:00", "Z") +def test_initialization_uses_never_expire_configured_token_as_is(mocker, tmp_path): + """A never-expire configured token is used directly with no mint or refresh thread.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _never_expire_metadata("Device token", BOOTSTRAP_TOKEN) + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == BOOTSTRAP_TOKEN + assert manager._current is None + assert manager._thread is None + assert not manager._slot_path.exists() + api.create_api_token.assert_not_called() + + +def test_initialization_mint_with_null_expires_at_does_not_start_refresh(mocker, tmp_path): + """A minted child with null expires_at is activated but does not start a refresh thread.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.create_api_token.return_value = _created_token( + "Device token abc123", "api_working_token_one", NOW, expires_at=None + ) + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" + assert manager._current is not None + assert manager._current.expires_at is None + assert manager._thread is None + cached = json.loads(manager._slot_path.read_text()) + assert cached["current"]["expires_at"] is None + + def test_initialization_reuses_valid_cached_token(mocker, tmp_path): """A valid slot is reused without making token API calls.""" first_api = Mock() - first_api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + first_api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) first_api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) first = _manager(mocker, tmp_path, first_api) @@ -105,10 +146,10 @@ def test_initialization_reuses_valid_cached_token(mocker, tmp_path): assert second._configuration.api_key["ApiToken"] == first._configuration.api_key["ApiToken"] -def test_initialization_uses_bootstrap_when_token_api_is_unavailable(mocker, tmp_path): - """A server without token management remains usable with the bootstrap token.""" +def test_initialization_uses_configured_token_when_token_api_is_unavailable(mocker, tmp_path): + """A server without token management remains usable with the configured token.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.side_effect = NotFoundException() api.create_api_token.side_effect = NotFoundException() manager = _manager(mocker, tmp_path, api) @@ -123,7 +164,7 @@ def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path) """Token naming uses the by-snippet endpoint (no pagination) and stays within 64 characters.""" api = Mock() long_name = "x" * 64 - api.get_api_token_by_snippet.return_value = _metadata(long_name, BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata(long_name, BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") @@ -138,23 +179,22 @@ def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path) def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): """When refresh is due, revoke previous, demote current, and mint a replacement.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - api.reset_mock() # clear calls from init (bootstrap lookup + mint) + api.reset_mock() # clear calls from init (lookup + mint) old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", "minted_at": NOW.isoformat(), } manager._slot_path.write_text(json.dumps(old_slot)) - later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) mocker.patch.object(token_manager, "_utc_now", return_value=later) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) manager.refresh() - # Rotation reads base_name from the slot; no snippet endpoint call is made. api.get_api_token_by_snippet.assert_not_called() api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) cached = json.loads(manager._slot_path.read_text()) @@ -163,10 +203,30 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): assert cached["previous"]["name"] == "Device token abc123" +def test_refresh_interval_is_observed_ttl_over_thirty(mocker, tmp_path): + """Refresh becomes due after one-thirtieth of the working token's observed lifetime.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + + before_due = NOW + REFRESH_INTERVAL - timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=before_due) + assert manager.refresh() is True + api.create_api_token.assert_not_called() + + after_due = NOW + REFRESH_INTERVAL + timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=after_due) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", after_due) + assert manager.refresh() is True + api.create_api_token.assert_called_once() + + def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path): """A failed previous revoke does not block minting the next working token.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) slot = json.loads(manager._slot_path.read_text()) @@ -175,7 +235,7 @@ def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path) "minted_at": NOW.isoformat(), } manager._slot_path.write_text(json.dumps(slot)) - later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) mocker.patch.object(token_manager, "_utc_now", return_value=later) api.reset_mock() api.delete_api_token.side_effect = ApiException(status=500) @@ -192,12 +252,12 @@ def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path) def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): - """A failed refresh waits a short backoff instead of immediately retrying or waiting a full day.""" + """A failed refresh waits a short backoff instead of immediately retrying.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - mocker.patch.object(token_manager, "_utc_now", return_value=NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1)) + mocker.patch.object(token_manager, "_utc_now", return_value=NOW + REFRESH_INTERVAL + timedelta(seconds=1)) mocker.patch.object(manager, "refresh", return_value=False) stop_event = Mock() stop_event.is_set.return_value = False @@ -212,7 +272,7 @@ def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): """Closing waits for in-flight refresh work before closing its HTTP client.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) thread = Mock() @@ -228,7 +288,7 @@ def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): """A 401 reloads a token another process already wrote instead of minting again.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() @@ -245,7 +305,7 @@ def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): - """A 401 during bootstrap lookup raises with the server's response body.""" + """A 401 during configured-token lookup raises with the server's response body.""" api = Mock() api.get_api_token_by_snippet.side_effect = UnauthorizedException( http_resp=SimpleNamespace( @@ -263,7 +323,7 @@ def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): """A rejected cached token raises loudly when no fresher token is on disk.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -285,8 +345,7 @@ def test_new_token_name_appends_suffix_and_truncates(mocker): def test_resolve_base_name_strips_existing_suffix(mocker, tmp_path): """The base_name established from an existing token has any prior hex suffix stripped.""" api = Mock() - # Bootstrap token already has a suffix from a previous rotation cycle. - api.get_api_token_by_snippet.return_value = _metadata("Device token abc123", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_one", NOW) _manager(mocker, tmp_path, api) @@ -301,7 +360,7 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): loose_dir.mkdir() loose_dir.chmod(0o777) # noqa: S103 # intentionally over-permissive to prove it gets tightened api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) _manager(mocker, loose_dir, api) @@ -312,7 +371,7 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, tmp_path): """A failed 401 recovery raises loudly and leaves the active token unchanged.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -333,26 +392,23 @@ def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): def test_refresh_falls_back_to_snippet_endpoint_for_old_format_slot(mocker, tmp_path): """A slot written before the base_name field existed triggers a snippet lookup on the next refresh.""" api = Mock() - api.get_api_token_by_snippet.return_value = _metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() - # Simulate a slot written by an older SDK version (no base_name field). slot_data = json.loads(manager._slot_path.read_text()) del slot_data["base_name"] manager._slot_path.write_text(json.dumps(slot_data)) - later = NOW + timedelta(days=REFRESH_INTERVAL_DAYS, seconds=1) + later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) mocker.patch.object(token_manager, "_utc_now", return_value=later) - api.get_api_token_by_snippet.return_value = _metadata("Device token abc123", "api_working_token_one") + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", "api_working_token_one") api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) manager.refresh() - # Fallback lookup was needed because base_name was absent. api.get_api_token_by_snippet.assert_called_once() cached = json.loads(manager._slot_path.read_text()) - # After the refresh the slot has base_name for future rotations. assert cached["base_name"] == "Device token" assert cached["current"]["raw_key"] == "api_working_token_two" From e5862ddf07e6b9f95e25dc8de5bc2c1477d0215a Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 24 Jul 2026 17:19:51 +0000 Subject: [PATCH 20/30] Automatically reformatting code --- src/groundlight/token_manager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index bd3db520..1a11d97e 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -307,11 +307,7 @@ def recover_from_unauthorized(self, detail: Optional[str] = None) -> None: try: with self._lock: slot = self._load_slot() - if ( - slot - and slot.current.raw_key != failed_token - and self._is_usable_cached_token(slot.current) - ): + if slot and slot.current.raw_key != failed_token and self._is_usable_cached_token(slot.current): self._activate(slot.current) return raise TokenManagerError(rejection) From eaa7048f6b6f0459a5f46f38e1ad8576ba3f796d Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 17:33:32 +0000 Subject: [PATCH 21/30] Harden token rotation and remove the refresh env knob Replay multipart file bytes on 401 retry, derive refresh cadence from server created_at/expires_at with a non-positive TTL clamp, and drop GROUNDLIGHT_DISABLE_TOKEN_REFRESH so rotate-vs-not is only expires_at. Also rename bootstrap_* to configured_*, require current cache fields, treat by-snippet 404 as no-rotation, and simplify mint previous wiring. Co-authored-by: Cursor --- src/groundlight/client.py | 14 +-- src/groundlight/config.py | 9 +- src/groundlight/internalapi.py | 53 +++++++++- src/groundlight/token_manager.py | 138 +++++++++++-------------- test/conftest.py | 10 +- test/unit/test_token_manager.py | 118 +++++++++++---------- test/unit/test_token_refresh_client.py | 32 +++++- 7 files changed, 216 insertions(+), 158 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index f8f615fc..b19fc86e 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -45,7 +45,6 @@ API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME, - DISABLE_TOKEN_REFRESH_VARIABLE_NAME, ) from groundlight.encodings import url_encode_dict from groundlight.images import ByteStreamWrapper, parse_supported_image_types, shrink_image_if_needed @@ -201,7 +200,7 @@ def __init__( self.api_client = GroundlightApiClient(self.configuration) try: self._token_manager = TokenManager( - bootstrap_token=api_token, + configured_token=api_token, configuration=self.configuration, request_timeout=DEFAULT_REQUEST_TIMEOUT, ) @@ -217,15 +216,8 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() - # Tests set GROUNDLIGHT_DISABLE_TOKEN_REFRESH so background refresh does not race with - # suites that mock the HTTP transport and assert exact call counts. - disable_refresh = os.environ.get(DISABLE_TOKEN_REFRESH_VARIABLE_NAME, "").lower() in { - "1", - "true", - "yes", - } - if not disable_refresh: - self._token_manager.start() + # No-op when the working token never expires (ttl is null). + self._token_manager.start() def __repr__(self) -> str: """Return a concise description of the connected client.""" diff --git a/src/groundlight/config.py b/src/groundlight/config.py index 99fe8ded..c254d46d 100644 --- a/src/groundlight/config.py +++ b/src/groundlight/config.py @@ -3,16 +3,9 @@ DEFAULT_ENDPOINT = "https://api.groundlight.ai/" DISABLE_TLS_VARIABLE_NAME = "DISABLE_TLS_VERIFY" -DISABLE_TOKEN_REFRESH_VARIABLE_NAME = "GROUNDLIGHT_DISABLE_TOKEN_REFRESH" -__all__ = [ - "API_TOKEN_WEB_URL", - "API_TOKEN_VARIABLE_NAME", - "DEFAULT_ENDPOINT", - "DISABLE_TLS_VARIABLE_NAME", - "DISABLE_TOKEN_REFRESH_VARIABLE_NAME", -] +__all__ = ["API_TOKEN_WEB_URL", "API_TOKEN_VARIABLE_NAME", "DEFAULT_ENDPOINT", "DISABLE_TLS_VARIABLE_NAME"] API_TOKEN_MISSING_HELP_MESSAGE = ( "No API token found. Please put your token in an environment variable " diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index a604f0af..92266a78 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -245,15 +245,60 @@ def call_api(self, *args, **kwargs): def request_with_unauthorized_recovery(self, method: str, url: str, **kwargs) -> requests.Response: """Send a raw request and retry once with refreshed credentials after a 401.""" - response = requests.request(method, url, **kwargs) + files_snapshot = self._snapshot_multipart_files(kwargs.get("files")) + request_kwargs = dict(kwargs) + if files_snapshot is not None: + request_kwargs["files"] = self._files_from_snapshot(files_snapshot) + + response = requests.request(method, url, **request_kwargs) if response.status_code != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: return response detail = (response.text or "").strip() or None self._unauthorized_handler(detail) - headers = dict(kwargs.get("headers", {})) + headers = dict(request_kwargs.get("headers", {})) headers["x-api-token"] = self.configuration.api_key["ApiToken"] - kwargs["headers"] = headers - return requests.request(method, url, **kwargs) + request_kwargs["headers"] = headers + if files_snapshot is not None: + request_kwargs["files"] = self._files_from_snapshot(files_snapshot) + return requests.request(method, url, **request_kwargs) + + @staticmethod + def _read_multipart_file_bytes(fileobj) -> bytes: + """Read bytes from a multipart file value without assuming it is seekable.""" + if isinstance(fileobj, (bytes, bytearray)): + return bytes(fileobj) + if isinstance(fileobj, io.IOBase): + data = fileobj.read() + if isinstance(data, str): + return data.encode("utf-8") + return data + raise TypeError(f"Unsupported multipart file type: {type(fileobj)!r}") + + @classmethod + def _snapshot_multipart_files(cls, files) -> Optional[dict]: + """Copy multipart file payloads so a 401 retry can resend them.""" + if files is None: + return None + snapshot = {} + for field, value in files.items(): + if isinstance(value, tuple): + filename, fileobj, *rest = value + snapshot[field] = (filename, cls._read_multipart_file_bytes(fileobj), *rest) + else: + snapshot[field] = cls._read_multipart_file_bytes(value) + return snapshot + + @staticmethod + def _files_from_snapshot(snapshot: dict) -> dict: + """Rebuild a requests-compatible files dict from a bytes snapshot.""" + files = {} + for field, value in snapshot.items(): + if isinstance(value, tuple): + filename, data, *rest = value + files[field] = (filename, io.BytesIO(data), *rest) + else: + files[field] = io.BytesIO(value) + return files # # The methods below will eventually go away when we move to properly model diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 1a11d97e..b3296fe3 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -25,8 +25,8 @@ logger = logging.getLogger("groundlight.sdk") TOKEN_SNIPPET_LENGTH = 20 -# Refresh fires after this fraction of the working token's observed lifetime -# (expires_at - minted_at). Example: 30-day TTL => refresh every 1 day. +# Refresh fires after this fraction of the server-reported token lifetime +# (expires_at - created_at). Example: 30-day TTL => refresh every 1 day. REFRESH_INTERVAL_FRACTION = 1 / 30 TOKEN_NAME_MAX_LENGTH = 64 TOKEN_NAME_SUFFIX_LENGTH = 7 @@ -34,11 +34,8 @@ # After a failed background refresh the current token is still valid for the rest of its # TTL, so retry on a short cadence to recover quickly from a transient outage rather than # waiting a full refresh interval (which would also spin when the token is already overdue). +# Also used as the floor when server lifetime is non-positive (clock skew). REFRESH_RETRY_BACKOFF_SECONDS = 5 * 60 -# Previous-token grace equals one refresh interval by construction: we only rotate when -# current is at least one refresh interval old, and previous was demoted at that mint time. -# Matches the trailing " xxxxxx" hex suffix this class appends, so repeated rotations -# reuse a stable base name instead of accreting a new suffix each cycle. TOKEN_NAME_SUFFIX_PATTERN = re.compile(r" [0-9a-f]{6}$") @@ -73,27 +70,35 @@ def _normalize_datetime(value: datetime) -> datetime: @dataclass(frozen=True) class CurrentToken: - """Store the working token data needed for authentication and rotation.""" + """Store the working token data needed for authentication and rotation. + + expires_at comes from the server. minted_at is the local observation time used to + schedule refresh. ttl is the server-side lifetime (expires_at - created_at) used to + compute the refresh cadence without mixing server and client clocks. + """ raw_key: str snippet: str name: str expires_at: Optional[datetime] minted_at: datetime + ttl: Optional[timedelta] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "CurrentToken": """Build a current token from its on-disk representation.""" expires_raw = data.get("expires_at") + ttl_seconds = data["ttl_seconds"] return cls( raw_key=data["raw_key"], snippet=data["snippet"], name=data["name"], expires_at=_parse_datetime(expires_raw) if expires_raw is not None else None, minted_at=_parse_datetime(data["minted_at"]), + ttl=timedelta(seconds=ttl_seconds) if ttl_seconds is not None else None, ) - def to_dict(self) -> Dict[str, Optional[str]]: + def to_dict(self) -> Dict[str, Any]: """Convert the current token to its JSON-compatible representation.""" return { "raw_key": self.raw_key, @@ -101,6 +106,7 @@ def to_dict(self) -> Dict[str, Optional[str]]: "name": self.name, "expires_at": _format_datetime(self.expires_at) if self.expires_at is not None else None, "minted_at": _format_datetime(self.minted_at), + "ttl_seconds": self.ttl.total_seconds() if self.ttl is not None else None, } @@ -126,9 +132,7 @@ class TokenSlot: """Represent the current and previous tokens stored in one cache slot. base_name is the configured token's human-readable name with any auto-generated suffix - stripped. It is established once at first mint and reused for all future rotations, - so a token chain always looks like "My Sensor ab12cd", "My Sensor ef34gh", ... rather - than accumulating nested suffixes. + stripped. It is established once at first mint and reused for all future rotations. """ base_name: str @@ -140,9 +144,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "TokenSlot": """Build a token slot from its on-disk representation.""" previous_data = data.get("previous") return cls( - # Empty string for slots written before this field existed; callers fall back - # to a live lookup when base_name is empty. - base_name=data.get("base_name", ""), + base_name=data["base_name"], current=CurrentToken.from_dict(data["current"]), previous=PreviousToken.from_dict(previous_data) if previous_data else None, ) @@ -161,16 +163,16 @@ class TokenManager: # pylint: disable=too-many-instance-attributes def __init__( self, - bootstrap_token: str, + configured_token: str, configuration: Configuration, request_timeout: float, token_dir: Optional[Path] = None, ): """Initialize the cache slot and select or mint a working API token.""" - self._bootstrap_token = bootstrap_token - self._bootstrap_snippet = bootstrap_token[:TOKEN_SNIPPET_LENGTH] - if len(self._bootstrap_snippet) != TOKEN_SNIPPET_LENGTH or not re.fullmatch( - r"[A-Za-z0-9_]+", self._bootstrap_snippet + self._configured_token = configured_token + self._configured_snippet = configured_token[:TOKEN_SNIPPET_LENGTH] + if len(self._configured_snippet) != TOKEN_SNIPPET_LENGTH or not re.fullmatch( + r"[A-Za-z0-9_]+", self._configured_snippet ): raise TokenManagerError( "The configured API token has an invalid format. Check that GROUNDLIGHT_API_TOKEN is set correctly." @@ -178,8 +180,8 @@ def __init__( self._configuration = configuration self._request_timeout = request_timeout self._token_dir = token_dir or self._default_token_dir() - self._slot_path = self._token_dir / f"{self._bootstrap_snippet}.json" - self._lock_path = self._token_dir / f"{self._bootstrap_snippet}.lock" + self._slot_path = self._token_dir / f"{self._configured_snippet}.json" + self._lock_path = self._token_dir / f"{self._configured_snippet}.lock" self._lock = FileLock(str(self._lock_path), timeout=LOCK_TIMEOUT_SECONDS, mode=0o600) self._stop_event = threading.Event() self._thread: Optional[threading.Thread] = None @@ -223,18 +225,15 @@ def _initialize_token(self) -> None: self._activate(slot.current) return - self._set_api_token(self._bootstrap_token) + self._set_api_token(self._configured_token) try: - configured_meta = self._get_token_by_snippet(self._bootstrap_snippet) + configured_meta = self._get_token_by_snippet(self._configured_snippet) except NotFoundException: - # by-snippet missing (old server) or unknown token: try minting with a - # generic base name; a subsequent 404 disables rotation. - self._mint_replacement( - base_name="sdk-auto", - slot=slot, - record_replaced_current=False, - previous=None, + # Token management API unavailable: keep the configured token as-is. + logger.warning( + "Automatic API token refresh is unavailable because this server does not support token management" ) + self._available = False return if configured_meta.expires_at is None: @@ -244,8 +243,6 @@ def _initialize_token(self) -> None: base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", configured_meta.name) self._mint_replacement( base_name=base_name, - slot=slot, - record_replaced_current=False, previous=PreviousToken(name=configured_meta.name, minted_at=_utc_now()), ) except FileLockTimeout as exc: @@ -255,7 +252,7 @@ def _initialize_token(self) -> None: "Automatic API token refresh is unavailable because this server does not support token management" ) self._available = False - self._set_api_token(self._bootstrap_token) + self._set_api_token(self._configured_token) except UnauthorizedException as exc: detail = api_exception_detail(exc) or "API token was rejected" raise TokenManagerError(detail) from exc @@ -275,14 +272,14 @@ def _is_usable_cached_token(token: CurrentToken) -> bool: return token.expires_at > _utc_now() def start(self) -> None: - """Start background refresh when the working token is set to expire.""" + """Start background refresh when the working token has a finite lifetime.""" if not self._available or self._thread is not None: return - if self._current is None or self._current.expires_at is None: + if self._current is None or self._current.ttl is None: return self._thread = threading.Thread( target=self._run, - name=f"gl-token-refresh-{self._bootstrap_snippet[:8]}", + name=f"gl-token-refresh-{self._configured_snippet[:8]}", daemon=True, ) self._thread.start() @@ -335,14 +332,15 @@ def refresh(self) -> bool: ) self._activate(slot.current) - if slot.current.expires_at is None: + if slot.current.expires_at is None or slot.current.ttl is None: return True if _utc_now() - slot.current.minted_at < self._refresh_interval(slot.current): return True - # Due for rotation: previous has been demoted for at least one refresh interval. self._revoke_previous(slot.previous) - base_name = slot.base_name or self._resolve_base_name(slot.current.snippet) - self._mint_replacement(base_name=base_name, slot=slot) + self._mint_replacement( + base_name=slot.base_name, + previous=PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at), + ) return True except TokenManagerError: raise @@ -364,7 +362,7 @@ def _run(self) -> None: """Refresh tokens on schedule until the client is closed.""" while not self._stop_event.is_set(): current = self._current - if current is None or current.expires_at is None: + if current is None or current.expires_at is None or current.ttl is None: return refresh_at = current.minted_at + self._refresh_interval(current) wait_seconds = max(0.0, (refresh_at - _utc_now()).total_seconds()) @@ -380,10 +378,17 @@ def _run(self) -> None: @staticmethod def _refresh_interval(token: CurrentToken) -> timedelta: - """Return how long to keep a working token before rotating.""" - if token.expires_at is None: + """Return how long to keep a working token before rotating. + + Uses the server-reported lifetime so client clock skew cannot invent a negative + interval. Non-positive lifetimes fall back to the retry backoff to avoid a spin. + """ + if token.ttl is None: raise TokenManagerError("Cannot compute a refresh interval for a never-expiring token") - return (token.expires_at - token.minted_at) * REFRESH_INTERVAL_FRACTION + interval = token.ttl * REFRESH_INTERVAL_FRACTION + if interval <= timedelta(0): + return timedelta(seconds=REFRESH_RETRY_BACKOFF_SECONDS) + return interval def _load_slot(self) -> Optional[TokenSlot]: """Load the cache slot, returning None when it does not exist.""" @@ -419,14 +424,7 @@ def _write_slot(self, slot: TokenSlot) -> None: if temporary_path and os.path.exists(temporary_path): os.unlink(temporary_path) - def _mint_replacement( - self, - base_name: str, - slot: Optional[TokenSlot], - *, - record_replaced_current: bool = True, - previous: Optional[PreviousToken] = None, - ) -> CurrentToken: + def _mint_replacement(self, base_name: str, previous: Optional[PreviousToken]) -> CurrentToken: """Mint a new token, persist the updated slot, and activate the new credential.""" new_name = self._new_token_name(base_name) minted_at = _utc_now() @@ -436,29 +434,10 @@ def _mint_replacement( _request_timeout=self._request_timeout, ) current = self._current_from_response(response, minted_at) - next_previous: Optional[PreviousToken] - if previous is not None: - next_previous = previous - elif slot and record_replaced_current: - next_previous = PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at) - else: - next_previous = slot.previous if slot else None - self._write_slot(TokenSlot(base_name=base_name, current=current, previous=next_previous)) + self._write_slot(TokenSlot(base_name=base_name, current=current, previous=previous)) self._activate(current) return current - def _resolve_base_name(self, snippet: str) -> str: - """Look up a token by snippet and return its name with any auto-generated suffix stripped. - - Used when reading an old-format slot file that predates the base_name field. - Falls back to 'sdk-auto' when no matching token is found. - """ - try: - token = self._get_token_by_snippet(snippet) - return TOKEN_NAME_SUFFIX_PATTERN.sub("", token.name) - except NotFoundException: - return "sdk-auto" - def _get_token_by_snippet(self, snippet: str) -> ApiToken: """Retrieve token metadata by snippet via the dedicated API endpoint.""" return self._api_tokens.get_api_token_by_snippet(snippet, _request_timeout=self._request_timeout) @@ -488,17 +467,24 @@ def _new_token_name(base_name: str) -> str: @staticmethod def _current_from_response(response: ApiTokenCreateResponse, minted_at: datetime) -> CurrentToken: """Convert a token creation response into cached current-token data.""" - expires_at: Optional[datetime] if response.expires_at is None: - expires_at = None - else: - expires_at = _normalize_datetime(response.expires_at) + return CurrentToken( + raw_key=response.raw_key, + snippet=response.raw_key_snippet, + name=response.name, + expires_at=None, + minted_at=minted_at, + ttl=None, + ) + expires_at = _normalize_datetime(response.expires_at) + created_at = _normalize_datetime(response.created_at) return CurrentToken( raw_key=response.raw_key, snippet=response.raw_key_snippet, name=response.name, expires_at=expires_at, minted_at=minted_at, + ttl=expires_at - created_at, ) def _activate(self, token: CurrentToken) -> None: diff --git a/test/conftest.py b/test/conftest.py index 330926c3..28b1fc90 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,16 @@ -import os from datetime import datetime from typing import Callable, Iterator from uuid import uuid4 import pytest from groundlight import ExperimentalApi, Groundlight -from groundlight.config import DISABLE_TOKEN_REFRESH_VARIABLE_NAME +from groundlight.token_manager import TokenManager from model import Detector, ImageQuery, ImageQueryTypeEnum, ResultTypeEnum -# Keep background token refresh off for the test suite. Short testing TTL/refresh intervals -# otherwise race with tests that mock urllib3/requests and assert exact call counts. -os.environ[DISABLE_TOKEN_REFRESH_VARIABLE_NAME] = "1" +# Keep background token refresh off for the test suite so it cannot race with tests that +# mock urllib3/requests and assert exact call counts. Production clients have no such knob; +# rotate-vs-not is determined solely by token expires_at / ttl. +TokenManager.start = lambda self: None # type: ignore[method-assign] def _generate_unique_detector_name(prefix: str = "Test") -> str: diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 3ad44190..b3be0015 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -17,12 +17,13 @@ from groundlight_openapi_client import Configuration from groundlight_openapi_client.exceptions import ApiException, NotFoundException, UnauthorizedException -BOOTSTRAP_TOKEN = "api_bootstrap_token_value_long_enough" +CONFIGURED_TOKEN = "api_configured_token_value_long" NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) TOKEN_TTL = timedelta(days=30) REFRESH_INTERVAL = TOKEN_TTL * REFRESH_INTERVAL_FRACTION TOKEN_CACHE_MODE = 0o600 TOKEN_DIR_MODE = 0o700 +_UNSET = object() def _expiring_metadata(name: str, raw_key: str) -> SimpleNamespace: @@ -35,13 +36,22 @@ def _never_expire_metadata(name: str, raw_key: str) -> SimpleNamespace: return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20], expires_at=None) -def _created_token(name: str, raw_key: str, now: datetime, *, expires_at=...) -> SimpleNamespace: +def _created_token( + name: str, + raw_key: str, + now: datetime, + *, + expires_at=_UNSET, + created_at: datetime = None, +) -> SimpleNamespace: """Build a token creation response. Pass expires_at=None for a never-expire child.""" - resolved_expires_at = now + TOKEN_TTL if expires_at is ... else expires_at + resolved_created_at = now if created_at is None else created_at + resolved_expires_at = now + TOKEN_TTL if expires_at is _UNSET else expires_at return SimpleNamespace( name=name, raw_key=raw_key, raw_key_snippet=raw_key[:20], + created_at=resolved_created_at, expires_at=resolved_expires_at, ) @@ -51,9 +61,9 @@ def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: mocker.patch.object(token_manager, "ApiTokensApi", return_value=api) mocker.patch.object(token_manager, "_utc_now", return_value=now) configuration = Configuration(host="https://example.com/device-api") - configuration.api_key["ApiToken"] = BOOTSTRAP_TOKEN + configuration.api_key["ApiToken"] = CONFIGURED_TOKEN return TokenManager( - bootstrap_token=BOOTSTRAP_TOKEN, + configured_token=CONFIGURED_TOKEN, configuration=configuration, request_timeout=1, token_dir=tmp_path, @@ -63,7 +73,7 @@ def _manager(mocker, tmp_path, api, now=NOW) -> TokenManager: def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): """An expiring configured token mints a child and stores it with mode 0600.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -76,6 +86,7 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): cached = json.loads(manager._slot_path.read_text()) assert cached["base_name"] == "Device token" assert cached["current"]["raw_key"] == "api_working_token_one" + assert cached["current"]["ttl_seconds"] == TOKEN_TTL.total_seconds() assert cached["previous"]["name"] == "Device token" api.delete_api_token.assert_not_called() @@ -83,7 +94,7 @@ def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): def test_initialization_parks_configured_token_as_previous(mocker, tmp_path): """After minting the first working token, the configured token is parked as previous.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -97,12 +108,12 @@ def test_initialization_parks_configured_token_as_previous(mocker, tmp_path): def test_initialization_uses_never_expire_configured_token_as_is(mocker, tmp_path): """A never-expire configured token is used directly with no mint or refresh thread.""" api = Mock() - api.get_api_token_by_snippet.return_value = _never_expire_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _never_expire_metadata("Device token", CONFIGURED_TOKEN) manager = _manager(mocker, tmp_path, api) manager.start() - assert manager._configuration.api_key["ApiToken"] == BOOTSTRAP_TOKEN + assert manager._configuration.api_key["ApiToken"] == CONFIGURED_TOKEN assert manager._current is None assert manager._thread is None assert not manager._slot_path.exists() @@ -112,7 +123,7 @@ def test_initialization_uses_never_expire_configured_token_as_is(mocker, tmp_pat def test_initialization_mint_with_null_expires_at_does_not_start_refresh(mocker, tmp_path): """A minted child with null expires_at is activated but does not start a refresh thread.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token( "Device token abc123", "api_working_token_one", NOW, expires_at=None ) @@ -126,20 +137,21 @@ def test_initialization_mint_with_null_expires_at_does_not_start_refresh(mocker, assert manager._thread is None cached = json.loads(manager._slot_path.read_text()) assert cached["current"]["expires_at"] is None + assert cached["current"]["ttl_seconds"] is None def test_initialization_reuses_valid_cached_token(mocker, tmp_path): """A valid slot is reused without making token API calls.""" first_api = Mock() - first_api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + first_api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) first_api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) first = _manager(mocker, tmp_path, first_api) second_api = Mock() mocker.patch.object(token_manager, "ApiTokensApi", return_value=second_api) configuration = Configuration(host="https://example.com/device-api") - configuration.api_key["ApiToken"] = BOOTSTRAP_TOKEN - second = TokenManager(BOOTSTRAP_TOKEN, configuration, request_timeout=1, token_dir=tmp_path) + configuration.api_key["ApiToken"] = CONFIGURED_TOKEN + second = TokenManager(CONFIGURED_TOKEN, configuration, request_timeout=1, token_dir=tmp_path) second_api.get_api_token_by_snippet.assert_not_called() second_api.create_api_token.assert_not_called() @@ -150,27 +162,27 @@ def test_initialization_uses_configured_token_when_token_api_is_unavailable(mock """A server without token management remains usable with the configured token.""" api = Mock() api.get_api_token_by_snippet.side_effect = NotFoundException() - api.create_api_token.side_effect = NotFoundException() manager = _manager(mocker, tmp_path, api) manager.start() - assert manager._configuration.api_key["ApiToken"] == BOOTSTRAP_TOKEN + assert manager._configuration.api_key["ApiToken"] == CONFIGURED_TOKEN assert manager._thread is None - api.create_api_token.assert_called_once() + assert manager._available is False + api.create_api_token.assert_not_called() def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path): - """Token naming uses the by-snippet endpoint (no pagination) and stays within 64 characters.""" + """Token naming uses the by-snippet endpoint and stays within 64 characters.""" api = Mock() long_name = "x" * 64 - api.get_api_token_by_snippet.return_value = _expiring_metadata(long_name, BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata(long_name, CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") _manager(mocker, tmp_path, api) - api.get_api_token_by_snippet.assert_called_once_with(BOOTSTRAP_TOKEN[:20], _request_timeout=1) + api.get_api_token_by_snippet.assert_called_once_with(CONFIGURED_TOKEN[:20], _request_timeout=1) request = api.create_api_token.call_args.args[0] assert request.name == f"{'x' * 57} abc123" assert len(request.name) == TOKEN_NAME_MAX_LENGTH @@ -179,10 +191,10 @@ def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path) def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): """When refresh is due, revoke previous, demote current, and mint a replacement.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) - api.reset_mock() # clear calls from init (lookup + mint) + api.reset_mock() old_slot = json.loads(manager._slot_path.read_text()) old_slot["previous"] = { "name": "older token", @@ -204,9 +216,9 @@ def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): def test_refresh_interval_is_observed_ttl_over_thirty(mocker, tmp_path): - """Refresh becomes due after one-thirtieth of the working token's observed lifetime.""" + """Refresh becomes due after one-thirtieth of the working token's server lifetime.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() @@ -226,7 +238,7 @@ def test_refresh_interval_is_observed_ttl_over_thirty(mocker, tmp_path): def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path): """A failed previous revoke does not block minting the next working token.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) slot = json.loads(manager._slot_path.read_text()) @@ -254,7 +266,7 @@ def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path) def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): """A failed refresh waits a short backoff instead of immediately retrying.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) mocker.patch.object(token_manager, "_utc_now", return_value=NOW + REFRESH_INTERVAL + timedelta(seconds=1)) @@ -272,7 +284,7 @@ def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): """Closing waits for in-flight refresh work before closing its HTTP client.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) thread = Mock() @@ -288,7 +300,7 @@ def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): """A 401 reloads a token another process already wrote instead of minting again.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) api.reset_mock() @@ -323,7 +335,7 @@ def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): """A rejected cached token raises loudly when no fresher token is on disk.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -345,12 +357,12 @@ def test_new_token_name_appends_suffix_and_truncates(mocker): def test_resolve_base_name_strips_existing_suffix(mocker, tmp_path): """The base_name established from an existing token has any prior hex suffix stripped.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_one", NOW) _manager(mocker, tmp_path, api) - cached = json.loads((tmp_path / f"{BOOTSTRAP_TOKEN[:20]}.json").read_text()) + cached = json.loads((tmp_path / f"{CONFIGURED_TOKEN[:20]}.json").read_text()) assert cached["base_name"] == "Device token" @@ -360,7 +372,7 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): loose_dir.mkdir() loose_dir.chmod(0o777) # noqa: S103 # intentionally over-permissive to prove it gets tightened api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) _manager(mocker, loose_dir, api) @@ -368,10 +380,10 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): assert stat.S_IMODE(loose_dir.stat().st_mode) == TOKEN_DIR_MODE -def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, tmp_path): +def test_unauthorized_recovery_leaves_active_token_unchanged_when_no_fresher(mocker, tmp_path): """A failed 401 recovery raises loudly and leaves the active token unchanged.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) manager = _manager(mocker, tmp_path, api) @@ -381,7 +393,7 @@ def test_unauthorized_recovery_restores_previous_token_when_remint_fails(mocker, assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" -def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): +def test_invalid_configured_token_cannot_escape_cache_directory(tmp_path): """Invalid token snippets are rejected before cache paths are created.""" configuration = Configuration(host="https://example.com/device-api") @@ -389,26 +401,28 @@ def test_invalid_bootstrap_token_cannot_escape_cache_directory(tmp_path): TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) -def test_refresh_falls_back_to_snippet_endpoint_for_old_format_slot(mocker, tmp_path): - """A slot written before the base_name field existed triggers a snippet lookup on the next refresh.""" +def test_refresh_interval_uses_server_ttl_and_clamps_non_positive(mocker, tmp_path): + """Refresh cadence uses server created_at/expires_at and avoids a zero/negative spin.""" api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", BOOTSTRAP_TOKEN) - api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token( + "Device token abc123", + "api_working_token_one", + NOW, + created_at=NOW + timedelta(minutes=10), + expires_at=NOW + timedelta(minutes=3), + ) manager = _manager(mocker, tmp_path, api) - api.reset_mock() - - slot_data = json.loads(manager._slot_path.read_text()) - del slot_data["base_name"] - manager._slot_path.write_text(json.dumps(slot_data)) - later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) - mocker.patch.object(token_manager, "_utc_now", return_value=later) - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", "api_working_token_one") - api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) + assert manager._current is not None + assert manager._refresh_interval(manager._current) == timedelta(seconds=REFRESH_RETRY_BACKOFF_SECONDS) - manager.refresh() + stop_event = Mock() + stop_event.is_set.return_value = False + stop_event.wait.side_effect = [True] + manager._stop_event = stop_event + mocker.patch.object(manager, "refresh") + manager._run() - api.get_api_token_by_snippet.assert_called_once() - cached = json.loads(manager._slot_path.read_text()) - assert cached["base_name"] == "Device token" - assert cached["current"]["raw_key"] == "api_working_token_two" + assert stop_event.wait.call_args_list[0] == call(float(REFRESH_RETRY_BACKOFF_SECONDS)) + manager.refresh.assert_not_called() diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index 0988ad4c..90387faa 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -11,9 +11,8 @@ EXPECTED_CALL_COUNT = 2 -def test_groundlight_starts_and_closes_token_manager(mocker, monkeypatch): +def test_groundlight_starts_and_closes_token_manager(mocker): """Groundlight owns the token manager lifecycle and supports context management.""" - monkeypatch.delenv("GROUNDLIGHT_DISABLE_TOKEN_REFRESH", raising=False) manager = Mock() token_manager_class = mocker.patch("groundlight.client.TokenManager", return_value=manager) mocker.patch.object(Groundlight, "_verify_connectivity") @@ -93,6 +92,35 @@ def refresh_token(_detail=None) -> None: assert request.call_args_list[1].kwargs["headers"]["x-api-token"] == "new-token" +def test_raw_request_replays_multipart_files_after_unauthorized(mocker): + """A 401 retry resends multipart file bytes even after the first attempt consumed the stream.""" + configuration = Configuration(host="https://example.com/device-api") + configuration.api_key["ApiToken"] = "old-token" + client = GroundlightApiClient(configuration) + client.set_unauthorized_handler(Mock()) + + image_payloads = [] + + def fake_request(_method, _url, **kwargs): + """Consume the multipart file stream like requests does when building the body.""" + fileobj = kwargs["files"]["image"][1] + image_payloads.append(fileobj.read()) + if len(image_payloads) == 1: + return Mock(status_code=HTTPStatus.UNAUTHORIZED, text="expired") + return Mock(status_code=HTTPStatus.OK, text="") + + mocker.patch("groundlight.internalapi.requests.request", side_effect=fake_request) + + response = client.request_with_unauthorized_recovery( + "POST", + "https://example.com/device-api/v1/notes", + files={"image": ("image.jpg", BytesIO(b"image bytes"), "image/jpeg")}, + ) + + assert response.status_code == HTTPStatus.OK + assert image_payloads == [b"image bytes", b"image bytes"] + + def test_create_note_uses_raw_request_token_recovery(): """Note creation routes its multipart request through token recovery.""" client = ExperimentalApi.__new__(ExperimentalApi) From cb7b81b18faee2ad3ea7297246864bf5da9f8e80 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 24 Jul 2026 17:34:38 +0000 Subject: [PATCH 22/30] Automatically reformatting code --- src/groundlight/token_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index b3296fe3..167e34e9 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -231,7 +231,8 @@ def _initialize_token(self) -> None: except NotFoundException: # Token management API unavailable: keep the configured token as-is. logger.warning( - "Automatic API token refresh is unavailable because this server does not support token management" + "Automatic API token refresh is unavailable because this server does not support token" + " management" ) self._available = False return From eaf4622dc2bd496bcd8a62a4f9dbb8d31ae11f9e Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 17:44:20 +0000 Subject: [PATCH 23/30] Tidy token-refresh comments and restore config import style Drop an unnecessary multi-line import diff and describe never-expire tokens via expires_at instead of ttl. Co-authored-by: Cursor --- src/groundlight/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index b19fc86e..257c225e 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -41,11 +41,7 @@ from urllib3.util.retry import Retry from groundlight.binary_labels import Label, convert_internal_label_to_display -from groundlight.config import ( - API_TOKEN_MISSING_HELP_MESSAGE, - API_TOKEN_VARIABLE_NAME, - DISABLE_TLS_VARIABLE_NAME, -) +from groundlight.config import API_TOKEN_MISSING_HELP_MESSAGE, API_TOKEN_VARIABLE_NAME, DISABLE_TLS_VARIABLE_NAME from groundlight.encodings import url_encode_dict from groundlight.images import ByteStreamWrapper, parse_supported_image_types, shrink_image_if_needed from groundlight.internalapi import ( @@ -216,7 +212,7 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() - # No-op when the working token never expires (ttl is null). + # No-op when the working token never expires (expires_at is null). self._token_manager.start() def __repr__(self) -> str: From c2c88159c66a3cd3e999cb167e0460e625b30662 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 17:54:49 +0000 Subject: [PATCH 24/30] Fix mypy errors in multipart retry helpers and token tests Annotate mixed multipart snapshot/file dict value types and make created_at Optional so the lint job can pass. Co-authored-by: Cursor --- src/groundlight/internalapi.py | 10 +++++----- test/unit/test_token_manager.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index 92266a78..5e364d61 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -8,7 +8,7 @@ from enum import Enum from functools import wraps from http import HTTPStatus -from typing import Callable, Optional, Tuple +from typing import Callable, Dict, Optional, Tuple, Union from urllib.parse import urlsplit, urlunsplit import requests @@ -275,11 +275,11 @@ def _read_multipart_file_bytes(fileobj) -> bytes: raise TypeError(f"Unsupported multipart file type: {type(fileobj)!r}") @classmethod - def _snapshot_multipart_files(cls, files) -> Optional[dict]: + def _snapshot_multipart_files(cls, files) -> Optional[Dict[str, Union[bytes, Tuple]]]: """Copy multipart file payloads so a 401 retry can resend them.""" if files is None: return None - snapshot = {} + snapshot: Dict[str, Union[bytes, Tuple]] = {} for field, value in files.items(): if isinstance(value, tuple): filename, fileobj, *rest = value @@ -289,9 +289,9 @@ def _snapshot_multipart_files(cls, files) -> Optional[dict]: return snapshot @staticmethod - def _files_from_snapshot(snapshot: dict) -> dict: + def _files_from_snapshot(snapshot: Dict[str, Union[bytes, Tuple]]) -> Dict[str, Union[io.BytesIO, Tuple]]: """Rebuild a requests-compatible files dict from a bytes snapshot.""" - files = {} + files: Dict[str, Union[io.BytesIO, Tuple]] = {} for field, value in snapshot.items(): if isinstance(value, tuple): filename, data, *rest = value diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index b3be0015..14e662ad 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -3,6 +3,7 @@ import stat from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from typing import Optional from unittest.mock import Mock, call import pytest @@ -42,7 +43,7 @@ def _created_token( now: datetime, *, expires_at=_UNSET, - created_at: datetime = None, + created_at: Optional[datetime] = None, ) -> SimpleNamespace: """Build a token creation response. Pass expires_at=None for a never-expire child.""" resolved_created_at = now if created_at is None else created_at From 9d975b97467eca4c405b1802bba920734591c276 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 17:59:35 +0000 Subject: [PATCH 25/30] Revert unnecessary Edge auth recovery wiring Edge config and readiness endpoints do not validate API tokens, so routing those calls through unauthorized recovery is not needed here. Co-authored-by: Cursor --- src/groundlight/edge/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/groundlight/edge/api.py b/src/groundlight/edge/api.py index b410ea13..3a918cee 100644 --- a/src/groundlight/edge/api.py +++ b/src/groundlight/edge/api.py @@ -31,11 +31,10 @@ def _base_url(self) -> str: return self._client.edge_base_url() def _request(self, method: str, path: str, **kwargs) -> requests.Response: - """Send an authenticated request to an Edge Endpoint.""" url = f"{self._base_url()}{path}" headers = self._client.get_raw_headers() try: - response = self._client.api_client.request_with_unauthorized_recovery( + response = requests.request( method, url, headers=headers, verify=self._client.configuration.verify_ssl, timeout=10, **kwargs ) response.raise_for_status() From 908621b868d592569b0906b762da0b8c0f594225 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 18:14:39 +0000 Subject: [PATCH 26/30] Remove mid-request 401 token recovery Rely on the previous-token grace period and background refresh instead of retrying OpenAPI and raw requests after unauthorized responses. Co-authored-by: Cursor --- src/groundlight/client.py | 1 - src/groundlight/experimental_api.py | 4 +- src/groundlight/internalapi.py | 118 ++----------------------- src/groundlight/token_manager.py | 24 ----- test/unit/test_http_retries.py | 11 +-- test/unit/test_token_manager.py | 45 ---------- test/unit/test_token_refresh_client.py | 118 ------------------------- 7 files changed, 10 insertions(+), 311 deletions(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 257c225e..20e66dcc 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -202,7 +202,6 @@ def __init__( ) except TokenManagerError as exc: raise ApiTokenError(str(exc)) from exc - self.api_client.set_unauthorized_handler(self._token_manager.recover_from_unauthorized) self.detectors_api = DetectorsApi(self.api_client) self.detector_group_api = DetectorGroupsApi(self.api_client) self.images_api = ImageQueriesApi(self.api_client) diff --git a/src/groundlight/experimental_api.py b/src/groundlight/experimental_api.py index 5f99ff63..8deb93e0 100644 --- a/src/groundlight/experimental_api.py +++ b/src/groundlight/experimental_api.py @@ -167,9 +167,7 @@ def create_note( params = {"detector_id": det_id} headers = {"x-api-token": self.configuration.api_key["ApiToken"]} - response = self.api_client.request_with_unauthorized_recovery( - "POST", url, headers=headers, data=data, files=files, params=params - ) + response = requests.post(url, headers=headers, data=data, files=files, params=params) # type: ignore response.raise_for_status() # Raise an exception for error status codes def ask_vlm_verify( # pylint: disable=too-many-locals diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index 5e364d61..2167451e 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -1,4 +1,3 @@ -import io import logging import os import platform @@ -7,8 +6,7 @@ import uuid from enum import Enum from functools import wraps -from http import HTTPStatus -from typing import Callable, Dict, Optional, Tuple, Union +from typing import Callable, Optional from urllib.parse import urlsplit, urlunsplit import requests @@ -19,7 +17,6 @@ from groundlight.version import get_version logger = logging.getLogger("groundlight.sdk") -REQUEST_BODY_ARG_INDEX = 5 class NotFoundError(Exception): @@ -180,45 +177,14 @@ class GroundlightApiClient(ApiClient): """ def __init__(self, *args, **kwargs): - """Initialize the generated API client with SDK-specific behavior.""" super().__init__(*args, **kwargs) self.user_agent = f"Groundlight-Python-SDK/{get_version()}/{platform.platform()}/{platform.python_version()}" - self._unauthorized_handler: Optional[Callable[[Optional[str]], None]] = None REQUEST_ID_HEADER = "X-Request-Id" - def set_unauthorized_handler(self, handler: Callable[[Optional[str]], None]) -> None: - """Set the callback used to recover and retry after a 401 response.""" - self._unauthorized_handler = handler - - @staticmethod - def _prepare_replayable_request_body(args: tuple, kwargs: dict) -> Tuple[tuple, dict, Optional[bytes], bool]: - """Copy a stream body so each request attempt receives a fresh stream.""" - body_is_keyword = "body" in kwargs - body = ( - kwargs.get("body") - if body_is_keyword - else (args[REQUEST_BODY_ARG_INDEX] if len(args) > REQUEST_BODY_ARG_INDEX else None) - ) - if not isinstance(body, io.IOBase): - return args, kwargs, None, body_is_keyword - try: - body_bytes = body.read() - finally: - body.close() - if body_is_keyword: - kwargs = dict(kwargs) - kwargs["body"] = io.BytesIO(body_bytes) - else: - replayable_args = list(args) - replayable_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(body_bytes) - args = tuple(replayable_args) - return args, kwargs, body_bytes, body_is_keyword - @RequestsRetryDecorator() def call_api(self, *args, **kwargs): - """Add a request ID and retry once after token recovery from a 401.""" - args, kwargs, replayable_body, body_is_keyword = self._prepare_replayable_request_body(args, kwargs) + """Adds a request-id header to each API call.""" # Note we don't look for header_param in kwargs here, because this method is only called in one place # in the generated code, so we can afford to make this brittle. header_param = args[4] # that's the number in the list @@ -228,77 +194,7 @@ def call_api(self, *args, **kwargs): elif not header_param.get(self.REQUEST_ID_HEADER, None): header_param[self.REQUEST_ID_HEADER] = _generate_request_id() # Note that we have updated the actual dict in args, so we don't have to put it back in - try: - return super().call_api(*args, **kwargs) - except ApiException as exc: - if exc.status != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: - raise - self._unauthorized_handler(api_exception_detail(exc)) - retry_args = list(args) - retry_kwargs = dict(kwargs) - if replayable_body is not None: - if body_is_keyword: - retry_kwargs["body"] = io.BytesIO(replayable_body) - else: - retry_args[REQUEST_BODY_ARG_INDEX] = io.BytesIO(replayable_body) - return super().call_api(*retry_args, **retry_kwargs) - - def request_with_unauthorized_recovery(self, method: str, url: str, **kwargs) -> requests.Response: - """Send a raw request and retry once with refreshed credentials after a 401.""" - files_snapshot = self._snapshot_multipart_files(kwargs.get("files")) - request_kwargs = dict(kwargs) - if files_snapshot is not None: - request_kwargs["files"] = self._files_from_snapshot(files_snapshot) - - response = requests.request(method, url, **request_kwargs) - if response.status_code != HTTPStatus.UNAUTHORIZED or self._unauthorized_handler is None: - return response - detail = (response.text or "").strip() or None - self._unauthorized_handler(detail) - headers = dict(request_kwargs.get("headers", {})) - headers["x-api-token"] = self.configuration.api_key["ApiToken"] - request_kwargs["headers"] = headers - if files_snapshot is not None: - request_kwargs["files"] = self._files_from_snapshot(files_snapshot) - return requests.request(method, url, **request_kwargs) - - @staticmethod - def _read_multipart_file_bytes(fileobj) -> bytes: - """Read bytes from a multipart file value without assuming it is seekable.""" - if isinstance(fileobj, (bytes, bytearray)): - return bytes(fileobj) - if isinstance(fileobj, io.IOBase): - data = fileobj.read() - if isinstance(data, str): - return data.encode("utf-8") - return data - raise TypeError(f"Unsupported multipart file type: {type(fileobj)!r}") - - @classmethod - def _snapshot_multipart_files(cls, files) -> Optional[Dict[str, Union[bytes, Tuple]]]: - """Copy multipart file payloads so a 401 retry can resend them.""" - if files is None: - return None - snapshot: Dict[str, Union[bytes, Tuple]] = {} - for field, value in files.items(): - if isinstance(value, tuple): - filename, fileobj, *rest = value - snapshot[field] = (filename, cls._read_multipart_file_bytes(fileobj), *rest) - else: - snapshot[field] = cls._read_multipart_file_bytes(value) - return snapshot - - @staticmethod - def _files_from_snapshot(snapshot: Dict[str, Union[bytes, Tuple]]) -> Dict[str, Union[io.BytesIO, Tuple]]: - """Rebuild a requests-compatible files dict from a bytes snapshot.""" - files: Dict[str, Union[io.BytesIO, Tuple]] = {} - for field, value in snapshot.items(): - if isinstance(value, tuple): - filename, data, *rest = value - files[field] = (filename, io.BytesIO(data), *rest) - else: - files[field] = io.BytesIO(value) - return files + return super().call_api(*args, **kwargs) # # The methods below will eventually go away when we move to properly model @@ -330,9 +226,7 @@ def _add_label(self, image_query_id: str, label: str) -> dict: headers = self._headers() logger.info(f"Posting label={label} to image_query {image_query_id} ...") - response = self.request_with_unauthorized_recovery( - "POST", url, json=data, headers=headers, verify=self.configuration.verify_ssl - ) + response = requests.request("POST", url, json=data, headers=headers, verify=self.configuration.verify_ssl) elapsed = 1000 * (time.time() - start_time) logger.debug(f"Call to ImageQuery.add_label took {elapsed:.1f}ms response={response.text}") @@ -353,9 +247,7 @@ def _get_detector_by_name(self, name: str) -> Detector: """ url = f"{self.configuration.host}/v1/detectors?name={name}" headers = self._headers() - response = self.request_with_unauthorized_recovery( - "GET", url, headers=headers, verify=self.configuration.verify_ssl - ) + response = requests.request("GET", url, headers=headers, verify=self.configuration.verify_ssl) if not is_ok(response.status_code): raise InternalApiError(status=response.status_code, http_resp=response) diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py index 167e34e9..fc1b487e 100644 --- a/src/groundlight/token_manager.py +++ b/src/groundlight/token_manager.py @@ -292,30 +292,6 @@ def close(self) -> None: self._thread.join() self._rotation_client.close() - def recover_from_unauthorized(self, detail: Optional[str] = None) -> None: - """Recover from a 401 by loading a fresher cached token written by another process. - - Does not remint from the configured token. If no fresher token is available on disk, - raise using the server's 401 detail when provided. - """ - if not self._available: - raise TokenManagerError("Automatic token recovery is unavailable on this server") - failed_token = self._configuration.api_key["ApiToken"] - rejection = (detail or "").strip() or "API token was rejected" - try: - with self._lock: - slot = self._load_slot() - if slot and slot.current.raw_key != failed_token and self._is_usable_cached_token(slot.current): - self._activate(slot.current) - return - raise TokenManagerError(rejection) - except FileLockTimeout as exc: - raise TokenManagerError("Timed out waiting to recover from an unauthorized API response") from exc - except TokenManagerError: - raise - except Exception as exc: # pylint: disable=broad-exception-caught - raise TokenManagerError("The cached token was rejected and could not be replaced") from exc - def refresh(self) -> bool: """Use the cached token if it is still fresh; otherwise rotate under the file lock. diff --git a/test/unit/test_http_retries.py b/test/unit/test_http_retries.py index 8b4c0c74..04b75393 100644 --- a/test/unit/test_http_retries.py +++ b/test/unit/test_http_retries.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Iterator +from typing import Any, Callable from unittest import mock import pytest @@ -14,13 +14,10 @@ @pytest.fixture(name="gl") -def groundlight_fixture() -> Iterator[Groundlight]: - """Create a Groundlight client for HTTP retry tests.""" +def groundlight_fixture() -> Groundlight: + "Creates a Groundlight client" gl = Groundlight() - try: - yield gl - finally: - gl.close() + return gl @pytest.fixture(name="detector") diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py index 14e662ad..e0dc7a07 100644 --- a/test/unit/test_token_manager.py +++ b/test/unit/test_token_manager.py @@ -298,25 +298,6 @@ def test_close_waits_for_refresh_thread_before_closing_client(mocker, tmp_path): rotation_client_close.assert_called_once_with() -def test_unauthorized_recovery_uses_newer_token_from_disk(mocker, tmp_path): - """A 401 reloads a token another process already wrote instead of minting again.""" - api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) - api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) - manager = _manager(mocker, tmp_path, api) - api.reset_mock() - - slot = json.loads(manager._slot_path.read_text()) - slot["current"]["raw_key"] = "api_newer_process_token" - slot["current"]["snippet"] = "api_newer_process_to" - manager._slot_path.write_text(json.dumps(slot)) - - manager.recover_from_unauthorized() - - assert manager._configuration.api_key["ApiToken"] == "api_newer_process_token" - api.create_api_token.assert_not_called() - - def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): """A 401 during configured-token lookup raises with the server's response body.""" api = Mock() @@ -333,19 +314,6 @@ def test_initialization_surfaces_unauthorized_detail(mocker, tmp_path): _manager(mocker, tmp_path, api) -def test_unauthorized_recovery_raises_when_no_fresher_token_available(mocker, tmp_path): - """A rejected cached token raises loudly when no fresher token is on disk.""" - api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) - api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) - manager = _manager(mocker, tmp_path, api) - - with pytest.raises(TokenManagerError, match="API identity has been revoked"): - manager.recover_from_unauthorized("API identity has been revoked") - - api.create_api_token.assert_called_once() # only during init, not during recovery - - def test_new_token_name_appends_suffix_and_truncates(mocker): """Token names append a unique hex suffix and never exceed the column limit.""" mocker.patch.object(token_manager.secrets, "token_hex", return_value="def456") @@ -381,19 +349,6 @@ def test_existing_token_dir_permissions_are_tightened(mocker, tmp_path): assert stat.S_IMODE(loose_dir.stat().st_mode) == TOKEN_DIR_MODE -def test_unauthorized_recovery_leaves_active_token_unchanged_when_no_fresher(mocker, tmp_path): - """A failed 401 recovery raises loudly and leaves the active token unchanged.""" - api = Mock() - api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) - api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) - manager = _manager(mocker, tmp_path, api) - - with pytest.raises(TokenManagerError, match="API token was rejected"): - manager.recover_from_unauthorized() - - assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" - - def test_invalid_configured_token_cannot_escape_cache_directory(tmp_path): """Invalid token snippets are rejected before cache paths are created.""" configuration = Configuration(host="https://example.com/device-api") diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py index 90387faa..7c58c0e2 100644 --- a/test/unit/test_token_refresh_client.py +++ b/test/unit/test_token_refresh_client.py @@ -1,14 +1,6 @@ -from http import HTTPStatus -from io import BytesIO from unittest.mock import Mock from groundlight.client import Groundlight -from groundlight.experimental_api import ExperimentalApi -from groundlight.internalapi import GroundlightApiClient -from groundlight_openapi_client import Configuration -from groundlight_openapi_client.api_client import ApiClient, ApiException - -EXPECTED_CALL_COUNT = 2 def test_groundlight_starts_and_closes_token_manager(mocker): @@ -26,113 +18,3 @@ def test_groundlight_starts_and_closes_token_manager(mocker): manager.close.assert_called_once() api_client_close.assert_called_once() - - -def test_api_client_recovers_and_retries_once_after_unauthorized(mocker): - """The custom API client refreshes credentials and retries one failed request.""" - configuration = Configuration(host="https://example.com/device-api") - client = GroundlightApiClient(configuration) - handler = Mock() - client.set_unauthorized_handler(handler) - parent_call = mocker.patch.object(ApiClient, "call_api", side_effect=[ApiException(status=401), "success"]) - - result = client.call_api("/v1/test", "GET", {}, [], {}) - - assert result == "success" - handler.assert_called_once_with(None) - assert parent_call.call_count == EXPECTED_CALL_COUNT - - -def test_api_client_replays_stream_body_after_unauthorized(mocker): - """A 401 retry resends stream content instead of reusing a closed stream.""" - configuration = Configuration(host="https://example.com/device-api") - client = GroundlightApiClient(configuration) - client.set_unauthorized_handler(Mock()) - request_bodies = [] - - def call_parent(*_args, **kwargs): - """Consume each stream like the generated API client does.""" - request_bodies.append(kwargs["body"].read()) - kwargs["body"].close() - if len(request_bodies) == 1: - raise ApiException(status=401) - return "success" - - mocker.patch.object(ApiClient, "call_api", side_effect=call_parent) - body = BytesIO(b"image bytes") - - result = client.call_api("/v1/image-queries", "POST", {}, [], {}, body=body) - - assert result == "success" - assert body.closed - assert request_bodies == [b"image bytes", b"image bytes"] - - -def test_raw_request_recovers_and_retries_once_after_unauthorized(mocker): - """Raw authenticated requests refresh their token and retry once after a 401.""" - configuration = Configuration(host="https://example.com/device-api") - configuration.api_key["ApiToken"] = "old-token" - client = GroundlightApiClient(configuration) - - def refresh_token(_detail=None) -> None: - """Simulate replacing the rejected token.""" - configuration.api_key["ApiToken"] = "new-token" - - client.set_unauthorized_handler(refresh_token) - unauthorized = Mock(status_code=HTTPStatus.UNAUTHORIZED) - success = Mock(status_code=HTTPStatus.OK) - request = mocker.patch("groundlight.internalapi.requests.request", side_effect=[unauthorized, success]) - - response = client.request_with_unauthorized_recovery( - "GET", "https://example.com/device-api/v1/detectors", headers={"x-api-token": "old-token"} - ) - - assert response is success - assert request.call_count == EXPECTED_CALL_COUNT - assert request.call_args_list[1].kwargs["headers"]["x-api-token"] == "new-token" - - -def test_raw_request_replays_multipart_files_after_unauthorized(mocker): - """A 401 retry resends multipart file bytes even after the first attempt consumed the stream.""" - configuration = Configuration(host="https://example.com/device-api") - configuration.api_key["ApiToken"] = "old-token" - client = GroundlightApiClient(configuration) - client.set_unauthorized_handler(Mock()) - - image_payloads = [] - - def fake_request(_method, _url, **kwargs): - """Consume the multipart file stream like requests does when building the body.""" - fileobj = kwargs["files"]["image"][1] - image_payloads.append(fileobj.read()) - if len(image_payloads) == 1: - return Mock(status_code=HTTPStatus.UNAUTHORIZED, text="expired") - return Mock(status_code=HTTPStatus.OK, text="") - - mocker.patch("groundlight.internalapi.requests.request", side_effect=fake_request) - - response = client.request_with_unauthorized_recovery( - "POST", - "https://example.com/device-api/v1/notes", - files={"image": ("image.jpg", BytesIO(b"image bytes"), "image/jpeg")}, - ) - - assert response.status_code == HTTPStatus.OK - assert image_payloads == [b"image bytes", b"image bytes"] - - -def test_create_note_uses_raw_request_token_recovery(): - """Note creation routes its multipart request through token recovery.""" - client = ExperimentalApi.__new__(ExperimentalApi) - client.endpoint = "https://example.com/device-api" - client.configuration = Configuration(host=client.endpoint) - client.configuration.api_key["ApiToken"] = "old-token" - client.api_client = Mock() - client.api_client.request_with_unauthorized_recovery.return_value = Mock() - - client.create_note("detector-id", "note text", image=b"image bytes") - - request = client.api_client.request_with_unauthorized_recovery - request.assert_called_once() - assert request.call_args.args[:2] == ("POST", "https://example.com/device-api/v1/notes") - assert request.call_args.kwargs["files"]["image"][1].read() == b"image bytes" From cb64ca239df62427fcd248244e88298dc81f5bd9 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 18:24:39 +0000 Subject: [PATCH 27/30] Drop unnecessary Groundlight.__repr__ docstring Co-authored-by: Cursor --- src/groundlight/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/groundlight/client.py b/src/groundlight/client.py index 20e66dcc..bc95a560 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -215,7 +215,6 @@ def __init__( self._token_manager.start() def __repr__(self) -> str: - """Return a concise description of the connected client.""" # 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}" From 277c59ba06f21c93b09d69ecea63be56ec2bb439 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 21:17:45 +0000 Subject: [PATCH 28/30] Fix ask_vlm_verify fixture for TokenManager init Use a long-enough fake token and stub TokenManager so these unit tests do not hit the network during ExperimentalApi construction. Co-authored-by: Cursor --- test/unit/test_ask_vlm_verify.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/unit/test_ask_vlm_verify.py b/test/unit/test_ask_vlm_verify.py index 0ce0555d..9366b420 100644 --- a/test/unit/test_ask_vlm_verify.py +++ b/test/unit/test_ask_vlm_verify.py @@ -5,6 +5,7 @@ server — the same layer the rest of the SDK's request plumbing runs through. """ +from typing import Iterator from unittest.mock import MagicMock, patch import pytest @@ -16,6 +17,8 @@ # Minimal valid-looking JPEG bytes for tests that don't exercise image encoding. _FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 16 +# Long enough for TokenManager's snippet validation (>= 20 chars). +_FAKE_API_TOKEN = "api_fake_test_token_xx" _RESPONSE_JSON = ( b'{"id":"vlmv_test123","type":"vlm_verification","created_at":"2025-06-17T00:00:00Z",' @@ -26,10 +29,13 @@ @pytest.fixture(name="gl") -def experimental_fixture(monkeypatch) -> ExperimentalApi: - monkeypatch.setenv("GROUNDLIGHT_API_TOKEN", "api_fake_test_token") - with patch.object(ExperimentalApi, "_verify_connectivity", return_value=None): - return ExperimentalApi(endpoint="http://test-server/device-api/") +def experimental_fixture(monkeypatch) -> Iterator[ExperimentalApi]: + """Build an ExperimentalApi without live connectivity or token-rotation network calls.""" + monkeypatch.setenv("GROUNDLIGHT_API_TOKEN", _FAKE_API_TOKEN) + with patch("groundlight.client.TokenManager", return_value=MagicMock()), patch.object( + ExperimentalApi, "_verify_connectivity", return_value=None + ): + yield ExperimentalApi(endpoint="http://test-server/device-api/") def _capturing_transport(captured: dict, data: bytes = _RESPONSE_JSON): From d2eb82f72743b4c51ba33eb641aac4db22139fd5 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Fri, 24 Jul 2026 21:18:27 +0000 Subject: [PATCH 29/30] Automatically reformatting code --- test/unit/test_ask_vlm_verify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/test_ask_vlm_verify.py b/test/unit/test_ask_vlm_verify.py index 9366b420..77588332 100644 --- a/test/unit/test_ask_vlm_verify.py +++ b/test/unit/test_ask_vlm_verify.py @@ -32,8 +32,9 @@ def experimental_fixture(monkeypatch) -> Iterator[ExperimentalApi]: """Build an ExperimentalApi without live connectivity or token-rotation network calls.""" monkeypatch.setenv("GROUNDLIGHT_API_TOKEN", _FAKE_API_TOKEN) - with patch("groundlight.client.TokenManager", return_value=MagicMock()), patch.object( - ExperimentalApi, "_verify_connectivity", return_value=None + with ( + patch("groundlight.client.TokenManager", return_value=MagicMock()), + patch.object(ExperimentalApi, "_verify_connectivity", return_value=None), ): yield ExperimentalApi(endpoint="http://test-server/device-api/") From 42f465a5aae955d96286b1cee7eccf72e1a26b2f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Fri, 24 Jul 2026 21:39:03 +0000 Subject: [PATCH 30/30] bumping version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 050a4ddb..e7fa4b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [ {include = "**/*.py", from = "src"}, ] readme = "README.md" -version = "0.30.0" +version = "0.31.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver