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/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", 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/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/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 fd4304cb..aaa553e5 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -1,3 +1,4 @@ +import io import logging import os import platform @@ -6,7 +7,8 @@ import uuid from enum import Enum from functools import wraps -from typing import Callable, Optional +from http import HTTPStatus +from typing import Callable, Optional, Tuple from urllib.parse import urlsplit, urlunsplit import requests @@ -17,6 +19,7 @@ from groundlight.version import get_version logger = logging.getLogger("groundlight.sdk") +REQUEST_BODY_ARG_INDEX = 5 class NotFoundError(Exception): @@ -164,14 +167,45 @@ 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 + + @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): - """Adds a request-id header to each API call.""" + """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) # 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 +215,31 @@ 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() + 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.""" + 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 @@ -213,7 +271,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}") @@ -234,7 +294,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 new file mode 100644 index 00000000..2fc5c856 --- /dev/null +++ b/src/groundlight/token_manager.py @@ -0,0 +1,480 @@ +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 +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 +# 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): + """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. + + 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 + + @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( + # 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, + ) + + 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, + } + + +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._available = True + + 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, 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 from the bootstrap token, then revoke the bootstrap.""" + 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) + # 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. + 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_api_name) + 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 _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: + 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() + self._rotation_client.close() + + 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). + """ + 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: + 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." + ) + 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 could not be replaced") from exc + + 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: + # 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): + 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. + 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( + "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 + + 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: + 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_RETRY_BACKOFF_SECONDS): + return + + 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, + 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)), + _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) + else: + previous = slot.previous if slot else None + 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) + + 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 True + grace_period = timedelta(days=CLEANUP_GRACE_FACTOR * REFRESH_INTERVAL_DAYS) + if _utc_now() - previous.minted_at < grace_period: + 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: str) -> str: + """Append a unique 6-character hex suffix to base_name, truncating to fit the column limit.""" + suffix = secrets.token_hex(3) + 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..02a7a02c --- /dev/null +++ b/test/unit/test_token_manager.py @@ -0,0 +1,335 @@ +# pylint: disable=protected-access +import json +import stat +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import Mock, call + +import pytest +from groundlight import token_manager +from groundlight.token_manager import ( + REFRESH_INTERVAL_DAYS, + REFRESH_RETRY_BACKOFF_SECONDS, + TOKEN_NAME_MAX_LENGTH, + TOKEN_TTL_DAYS, + TokenManager, + TokenManagerError, +) +from groundlight_openapi_client import Configuration +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) +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 _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 _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.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) + + 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["base_name"] == "Device token" + assert cached["current"]["raw_key"] == "api_working_token_one" + 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.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) + + 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() + 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) + + 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.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"] + + +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.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.create_api_token.assert_called_once() + + +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.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) + 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.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) + 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.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()) + assert cached["base_name"] == "Device token" + assert cached["current"]["raw_key"] == "api_working_token_two" + 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.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()) + 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 a short backoff instead of immediately retrying or waiting a full day.""" + 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 = _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_RETRY_BACKOFF_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.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() + 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() + 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() + + 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_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.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() + + 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") + + assert TokenManager._new_token_name("Device token") == "Device token 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.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) + + 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): + """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.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) + + 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.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) + + 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): + """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) + + +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.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.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.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" diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py new file mode 100644 index 00000000..b282841f --- /dev/null +++ b/test/unit/test_token_refresh_client.py @@ -0,0 +1,109 @@ +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): + """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 + + +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() -> 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_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"