diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index fc763407..928613a0 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 @@ -87,6 +92,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 @@ -108,6 +114,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 @@ -146,6 +155,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 b353ad26..fb70bd29 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} | @@ -156,6 +160,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) @@ -194,6 +201,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..7cfec464 --- /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, none_type** | The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used. | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[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..a0308e20 --- /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, none_type** | The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used. | [readonly] +**raw_key** | **str** | The full API token secret. Returned only once, when the token is created. | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[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 63b7497a..ec364260 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..c81cecff --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token.py @@ -0,0 +1,303 @@ +""" + 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, + none_type, + ), # 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..cc51c9b7 --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token_create_response.py @@ -0,0 +1,310 @@ +""" + 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, + none_type, + ), # 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 9f8727d8..fcc8fd76 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 911ca044..c71c112a 100644 --- a/generated/model.py +++ b/generated/model.py @@ -28,6 +28,55 @@ 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: Optional[datetime] = Field( + ..., + description="The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used.", + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + + +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: Optional[datetime] = Field( + ..., + description="The most recent time this API token was used. (Helpful for detecting suspicious activity). Null if the token has never been used.", + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + 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 +227,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 e174e1be..5136baa4 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 @@ -1066,6 +1162,101 @@ 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 + nullable: true + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). Null if the token has never been used. + expires_at: + 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 + nullable: true + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). Null if the token has never been used. + expires_at: + 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 @@ -1666,6 +1857,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 5a4a0cec..698ff655 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..bc95a560 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,14 @@ def __init__( self.configuration.api_key["ApiToken"] = api_token self.api_client = GroundlightApiClient(self.configuration) + try: + self._token_manager = TokenManager( + configured_token=api_token, + configuration=self.configuration, + request_timeout=DEFAULT_REQUEST_TIMEOUT, + ) + except TokenManagerError as exc: + raise ApiTokenError(str(exc)) from exc self.detectors_api = DetectorsApi(self.api_client) self.detector_group_api = DetectorGroupsApi(self.api_client) self.images_api = ImageQueriesApi(self.api_client) @@ -202,11 +211,26 @@ def __init__( self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) self.logged_in_user = "(not-logged-in)" self._verify_connectivity() + # No-op when the working token never expires (expires_at is null). + self._token_manager.start() def __repr__(self) -> str: # Don't call the API here because that can get us stuck in a loop rendering exception strings return f"Logged in as {self.logged_in_user} to Groundlight at {self.endpoint}" + def __enter__(self) -> "Groundlight": + """Return this client for use as a context manager.""" + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Stop background work when leaving a context manager.""" + self.close() + + def close(self) -> None: + """Stop the token refresh thread and close the HTTP client.""" + self._token_manager.close() + self.api_client.close() + def _verify_connectivity(self) -> None: """ Verify that the client can connect to the Groundlight service, and raise a helpful diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index fd4304cb..2167451e 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -23,6 +23,19 @@ class NotFoundError(Exception): pass +def api_exception_detail(exc: ApiException) -> Optional[str]: + """Return a stripped response body from an API exception, if present.""" + body = exc.body + if body is None: + return None + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace") + else: + text = str(body) + text = text.strip() + return text or None + + def sanitize_endpoint_url(endpoint: Optional[str] = None) -> str: """Takes a URL for an endpoint, and returns a "sanitized" version of it. Currently the production API path must be exactly "/device-api". diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py new file mode 100644 index 00000000..fc1b487e --- /dev/null +++ b/src/groundlight/token_manager.py @@ -0,0 +1,474 @@ +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, UnauthorizedException +from groundlight_openapi_client.model.api_token import ApiToken +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from platformdirs import user_data_path + +from groundlight.internalapi import GroundlightApiClient, api_exception_detail + +logger = logging.getLogger("groundlight.sdk") + +TOKEN_SNIPPET_LENGTH = 20 +# Refresh fires after this fraction of the server-reported token lifetime +# (expires_at - created_at). Example: 30-day TTL => refresh every 1 day. +REFRESH_INTERVAL_FRACTION = 1 / 30 +TOKEN_NAME_MAX_LENGTH = 64 +TOKEN_NAME_SUFFIX_LENGTH = 7 +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). +# Also used as the floor when server lifetime is non-positive (clock skew). +REFRESH_RETRY_BACKOFF_SECONDS = 5 * 60 +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. + + expires_at comes from the server. minted_at is the local observation time used to + schedule refresh. ttl is the server-side lifetime (expires_at - created_at) used to + compute the refresh cadence without mixing server and client clocks. + """ + + raw_key: str + snippet: str + name: str + expires_at: Optional[datetime] + minted_at: datetime + ttl: Optional[timedelta] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CurrentToken": + """Build a current token from its on-disk representation.""" + expires_raw = data.get("expires_at") + ttl_seconds = data["ttl_seconds"] + return cls( + raw_key=data["raw_key"], + snippet=data["snippet"], + name=data["name"], + expires_at=_parse_datetime(expires_raw) if expires_raw is not None else None, + minted_at=_parse_datetime(data["minted_at"]), + ttl=timedelta(seconds=ttl_seconds) if ttl_seconds is not None else None, + ) + + def to_dict(self) -> Dict[str, Any]: + """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) if self.expires_at is not None else None, + "minted_at": _format_datetime(self.minted_at), + "ttl_seconds": self.ttl.total_seconds() if self.ttl is not None else None, + } + + +@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 configured token's human-readable name with any auto-generated suffix + stripped. It is established once at first mint and reused for all future rotations. + """ + + 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( + base_name=data["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, + configured_token: str, + configuration: Configuration, + request_timeout: float, + token_dir: Optional[Path] = None, + ): + """Initialize the cache slot and select or mint a working API token.""" + self._configured_token = configured_token + self._configured_snippet = configured_token[:TOKEN_SNIPPET_LENGTH] + if len(self._configured_snippet) != TOKEN_SNIPPET_LENGTH or not re.fullmatch( + r"[A-Za-z0-9_]+", self._configured_snippet + ): + raise TokenManagerError( + "The configured API token has an invalid format. Check that GROUNDLIGHT_API_TOKEN is set correctly." + ) + 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._configured_snippet}.json" + self._lock_path = self._token_dir / f"{self._configured_snippet}.lock" + self._lock = FileLock(str(self._lock_path), timeout=LOCK_TIMEOUT_SECONDS, mode=0o600) + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + 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, use a never-expire configured token, or mint a child.""" + try: + with self._lock: + slot = self._load_slot() + if slot and self._is_usable_cached_token(slot.current): + self._activate(slot.current) + return + + self._set_api_token(self._configured_token) + try: + configured_meta = self._get_token_by_snippet(self._configured_snippet) + except NotFoundException: + # Token management API unavailable: keep the configured token as-is. + logger.warning( + "Automatic API token refresh is unavailable because this server does not support token" + " management" + ) + self._available = False + return + + if configured_meta.expires_at is None: + # Never-expire configured token: behave like pre-rotation Groundlight. + return + + base_name = TOKEN_NAME_SUFFIX_PATTERN.sub("", configured_meta.name) + self._mint_replacement( + base_name=base_name, + previous=PreviousToken(name=configured_meta.name, minted_at=_utc_now()), + ) + except FileLockTimeout as exc: + raise TokenManagerError(f"Timed out waiting for token cache lock '{self._lock_path}'") from exc + 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._configured_token) + except UnauthorizedException as exc: + detail = api_exception_detail(exc) or "API token was rejected" + raise TokenManagerError(detail) from exc + except TokenManagerError: + raise + except Exception as exc: # pylint: disable=broad-exception-caught + raise TokenManagerError( + "Unable to create a working API token. " + "Check that GROUNDLIGHT_API_TOKEN is set to a valid token for this endpoint." + ) from exc + + @staticmethod + def _is_usable_cached_token(token: CurrentToken) -> bool: + """Whether a cached token can be activated without minting.""" + if token.expires_at is None: + return True + return token.expires_at > _utc_now() + + def start(self) -> None: + """Start background refresh when the working token has a finite lifetime.""" + if not self._available or self._thread is not None: + return + if self._current is None or self._current.ttl is None: + return + self._thread = threading.Thread( + target=self._run, + name=f"gl-token-refresh-{self._configured_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 refresh(self) -> bool: + """Use the cached token if it is still fresh; otherwise rotate under the file lock. + + Rotation always completes the two-token cycle: revoke previous (best effort), demote + current to previous, and mint a new current. Returns False only when rotation could + not run (lock timeout, mint failure, or token API unavailable). + """ + try: + with self._lock: + slot = self._load_slot() + if slot is None: + raise TokenManagerError( + "Token cache slot is missing and cannot be refreshed. " + "Please provision a new GROUNDLIGHT_API_TOKEN." + ) + + self._activate(slot.current) + if slot.current.expires_at is None or slot.current.ttl is None: + return True + if _utc_now() - slot.current.minted_at < self._refresh_interval(slot.current): + return True + self._revoke_previous(slot.previous) + self._mint_replacement( + base_name=slot.base_name, + previous=PreviousToken(name=slot.current.name, minted_at=slot.current.minted_at), + ) + return True + except TokenManagerError: + raise + 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 + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Automatic API token refresh failed; the current token remains active", exc_info=True) + return False + + def _run(self) -> None: + """Refresh tokens on schedule until the client is closed.""" + while not self._stop_event.is_set(): + current = self._current + if current is None or current.expires_at is None or current.ttl is None: + return + refresh_at = current.minted_at + self._refresh_interval(current) + wait_seconds = max(0.0, (refresh_at - _utc_now()).total_seconds()) + 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 + + @staticmethod + def _refresh_interval(token: CurrentToken) -> timedelta: + """Return how long to keep a working token before rotating. + + Uses the server-reported lifetime so client clock skew cannot invent a negative + interval. Non-positive lifetimes fall back to the retry backoff to avoid a spin. + """ + if token.ttl is None: + raise TokenManagerError("Cannot compute a refresh interval for a never-expiring token") + interval = token.ttl * REFRESH_INTERVAL_FRACTION + if interval <= timedelta(0): + return timedelta(seconds=REFRESH_RETRY_BACKOFF_SECONDS) + return interval + + def _load_slot(self) -> Optional[TokenSlot]: + """Load the cache slot, returning None when it does not exist.""" + 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, previous: Optional[PreviousToken]) -> CurrentToken: + """Mint a new token, persist the updated slot, and activate the new credential.""" + new_name = self._new_token_name(base_name) + minted_at = _utc_now() + # Omit expires_at so the server applies the identity's token lifetime policy. + response = self._api_tokens.create_api_token( + ApiTokenRequest(name=new_name), + _request_timeout=self._request_timeout, + ) + current = self._current_from_response(response, minted_at) + self._write_slot(TokenSlot(base_name=base_name, current=current, previous=previous)) + self._activate(current) + return current + + 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 _revoke_previous(self, previous: Optional[PreviousToken]) -> None: + """Best-effort revoke of the demoted previous token before it is replaced in the slot.""" + if previous is None: + return + 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'; continuing rotation", + previous.name, + exc_info=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: + return CurrentToken( + raw_key=response.raw_key, + snippet=response.raw_key_snippet, + name=response.name, + expires_at=None, + minted_at=minted_at, + ttl=None, + ) + expires_at = _normalize_datetime(response.expires_at) + created_at = _normalize_datetime(response.created_at) + return CurrentToken( + raw_key=response.raw_key, + snippet=response.raw_key_snippet, + name=response.name, + expires_at=expires_at, + minted_at=minted_at, + ttl=expires_at - created_at, + ) + + def _activate(self, token: CurrentToken) -> None: + """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/conftest.py b/test/conftest.py index a3160457..28b1fc90 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,11 +1,17 @@ from datetime import datetime -from typing import Callable +from typing import Callable, Iterator from uuid import uuid4 import pytest from groundlight import ExperimentalApi, Groundlight +from groundlight.token_manager import TokenManager from model import Detector, ImageQuery, ImageQueryTypeEnum, ResultTypeEnum +# Keep background token refresh off for the test suite so it cannot race with tests that +# mock urllib3/requests and assert exact call counts. Production clients have no such knob; +# rotate-vs-not is determined solely by token expires_at / ttl. +TokenManager.start = lambda self: None # type: ignore[method-assign] + def _generate_unique_detector_name(prefix: str = "Test") -> str: """Generates a detector name with a timestamp and random suffix to ensure uniqueness.""" @@ -21,18 +27,24 @@ def fixture_detector_name() -> Callable[..., str]: def pytest_configure(config): # pylint: disable=unused-argument # Run environment check before tests gl = Groundlight() - if gl._user_is_privileged(): # pylint: disable=protected-access - raise RuntimeError( - "ERROR: You are running tests with a privileged user. Please run tests with a non-privileged user." - ) + try: + if gl._user_is_privileged(): # pylint: disable=protected-access + raise RuntimeError( + "ERROR: You are running tests with a privileged user. Please run tests with a non-privileged user." + ) + finally: + gl.close() @pytest.fixture(name="gl") -def fixture_gl() -> Groundlight: +def fixture_gl() -> Iterator[Groundlight]: """Creates a Groundlight client object for testing.""" _gl = Groundlight() _gl.DEFAULT_WAIT = 10 - return _gl + try: + yield _gl + finally: + _gl.close() @pytest.fixture(name="detector") @@ -82,10 +94,14 @@ def fixture_image_query_zero(gl_experimental: Groundlight, count_detector: Detec @pytest.fixture(name="gl_experimental") -def fixture_gl_experimental() -> ExperimentalApi: +def fixture_gl_experimental() -> Iterator[ExperimentalApi]: + """Creates an ExperimentalApi client object for testing.""" _gl = ExperimentalApi() _gl.DEFAULT_WAIT = 10 - return _gl + try: + yield _gl + finally: + _gl.close() @pytest.fixture(name="initial_iq") diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py new file mode 100644 index 00000000..e0dc7a07 --- /dev/null +++ b/test/unit/test_token_manager.py @@ -0,0 +1,384 @@ +# pylint: disable=protected-access +import json +import stat +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Optional +from unittest.mock import Mock, call + +import pytest +from groundlight import token_manager +from groundlight.token_manager import ( + REFRESH_INTERVAL_FRACTION, + REFRESH_RETRY_BACKOFF_SECONDS, + TOKEN_NAME_MAX_LENGTH, + TokenManager, + TokenManagerError, +) +from groundlight_openapi_client import Configuration +from groundlight_openapi_client.exceptions import ApiException, NotFoundException, UnauthorizedException + +CONFIGURED_TOKEN = "api_configured_token_value_long" +NOW = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc) +TOKEN_TTL = timedelta(days=30) +REFRESH_INTERVAL = TOKEN_TTL * REFRESH_INTERVAL_FRACTION +TOKEN_CACHE_MODE = 0o600 +TOKEN_DIR_MODE = 0o700 +_UNSET = object() + + +def _expiring_metadata(name: str, raw_key: str) -> SimpleNamespace: + """Build by-snippet metadata for an expiring configured token.""" + return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20], expires_at=NOW + TOKEN_TTL) + + +def _never_expire_metadata(name: str, raw_key: str) -> SimpleNamespace: + """Build by-snippet metadata for a never-expire configured token.""" + return SimpleNamespace(name=name, raw_key_snippet=raw_key[:20], expires_at=None) + + +def _created_token( + name: str, + raw_key: str, + now: datetime, + *, + expires_at=_UNSET, + created_at: Optional[datetime] = None, +) -> SimpleNamespace: + """Build a token creation response. Pass expires_at=None for a never-expire child.""" + resolved_created_at = now if created_at is None else created_at + resolved_expires_at = now + TOKEN_TTL if expires_at is _UNSET else expires_at + return SimpleNamespace( + name=name, + raw_key=raw_key, + raw_key_snippet=raw_key[:20], + created_at=resolved_created_at, + expires_at=resolved_expires_at, + ) + + +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"] = CONFIGURED_TOKEN + return TokenManager( + configured_token=CONFIGURED_TOKEN, + configuration=configuration, + request_timeout=1, + token_dir=tmp_path, + ) + + +def test_initialization_mints_and_privately_caches_token(mocker, tmp_path): + """An expiring configured token mints a child and stores it with mode 0600.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_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 "expires_at" not in request._data_store # server-authoritative mint; no client TTL + assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" + assert stat.S_IMODE(manager._slot_path.stat().st_mode) == TOKEN_CACHE_MODE + cached = json.loads(manager._slot_path.read_text()) + assert cached["base_name"] == "Device token" + assert cached["current"]["raw_key"] == "api_working_token_one" + assert cached["current"]["ttl_seconds"] == TOKEN_TTL.total_seconds() + assert cached["previous"]["name"] == "Device token" + api.delete_api_token.assert_not_called() + + +def test_initialization_parks_configured_token_as_previous(mocker, tmp_path): + """After minting the first working token, the configured token is parked as previous.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + + manager = _manager(mocker, tmp_path, api) + + api.delete_api_token.assert_not_called() + cached = json.loads(manager._slot_path.read_text()) + assert cached["previous"]["name"] == "Device token" + assert cached["previous"]["minted_at"] == NOW.isoformat().replace("+00:00", "Z") + + +def test_initialization_uses_never_expire_configured_token_as_is(mocker, tmp_path): + """A never-expire configured token is used directly with no mint or refresh thread.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _never_expire_metadata("Device token", CONFIGURED_TOKEN) + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == CONFIGURED_TOKEN + assert manager._current is None + assert manager._thread is None + assert not manager._slot_path.exists() + api.create_api_token.assert_not_called() + + +def test_initialization_mint_with_null_expires_at_does_not_start_refresh(mocker, tmp_path): + """A minted child with null expires_at is activated but does not start a refresh thread.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token( + "Device token abc123", "api_working_token_one", NOW, expires_at=None + ) + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == "api_working_token_one" + assert manager._current is not None + assert manager._current.expires_at is None + assert manager._thread is None + cached = json.loads(manager._slot_path.read_text()) + assert cached["current"]["expires_at"] is None + assert cached["current"]["ttl_seconds"] is None + + +def test_initialization_reuses_valid_cached_token(mocker, tmp_path): + """A valid slot is reused without making token API calls.""" + first_api = Mock() + first_api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + first_api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + first = _manager(mocker, tmp_path, first_api) + + second_api = Mock() + mocker.patch.object(token_manager, "ApiTokensApi", return_value=second_api) + configuration = Configuration(host="https://example.com/device-api") + configuration.api_key["ApiToken"] = CONFIGURED_TOKEN + second = TokenManager(CONFIGURED_TOKEN, configuration, request_timeout=1, token_dir=tmp_path) + + second_api.get_api_token_by_snippet.assert_not_called() + second_api.create_api_token.assert_not_called() + assert second._configuration.api_key["ApiToken"] == first._configuration.api_key["ApiToken"] + + +def test_initialization_uses_configured_token_when_token_api_is_unavailable(mocker, tmp_path): + """A server without token management remains usable with the configured token.""" + api = Mock() + api.get_api_token_by_snippet.side_effect = NotFoundException() + + manager = _manager(mocker, tmp_path, api) + manager.start() + + assert manager._configuration.api_key["ApiToken"] == CONFIGURED_TOKEN + assert manager._thread is None + assert manager._available is False + api.create_api_token.assert_not_called() + + +def test_name_lookup_uses_snippet_endpoint_and_enforces_length(mocker, tmp_path): + """Token naming uses the by-snippet endpoint and stays within 64 characters.""" + api = Mock() + long_name = "x" * 64 + api.get_api_token_by_snippet.return_value = _expiring_metadata(long_name, CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token(f"{'x' * 57} abc123", "api_working_token_one", NOW) + mocker.patch.object(token_manager.secrets, "token_hex", return_value="abc123") + + _manager(mocker, tmp_path, api) + + api.get_api_token_by_snippet.assert_called_once_with(CONFIGURED_TOKEN[:20], _request_timeout=1) + request = api.create_api_token.call_args.args[0] + assert request.name == f"{'x' * 57} abc123" + assert len(request.name) == TOKEN_NAME_MAX_LENGTH + + +def test_refresh_rotates_and_cleans_up_previous_token(mocker, tmp_path): + """When refresh is due, revoke previous, demote current, and mint a replacement.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + old_slot = json.loads(manager._slot_path.read_text()) + old_slot["previous"] = { + "name": "older token", + "minted_at": NOW.isoformat(), + } + manager._slot_path.write_text(json.dumps(old_slot)) + later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=later) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) + + manager.refresh() + + 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_interval_is_observed_ttl_over_thirty(mocker, tmp_path): + """Refresh becomes due after one-thirtieth of the working token's server lifetime.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + api.reset_mock() + + before_due = NOW + REFRESH_INTERVAL - timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=before_due) + assert manager.refresh() is True + api.create_api_token.assert_not_called() + + after_due = NOW + REFRESH_INTERVAL + timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=after_due) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", after_due) + assert manager.refresh() is True + api.create_api_token.assert_called_once() + + +def test_refresh_continues_rotation_when_previous_delete_fails(mocker, tmp_path): + """A failed previous revoke does not block minting the next working token.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + slot = json.loads(manager._slot_path.read_text()) + slot["previous"] = { + "name": "older token", + "minted_at": NOW.isoformat(), + } + manager._slot_path.write_text(json.dumps(slot)) + later = NOW + REFRESH_INTERVAL + timedelta(seconds=1) + mocker.patch.object(token_manager, "_utc_now", return_value=later) + api.reset_mock() + api.delete_api_token.side_effect = ApiException(status=500) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_two", later) + + refresh_succeeded = manager.refresh() + + assert refresh_succeeded + api.delete_api_token.assert_called_once_with("older token", _request_timeout=1) + api.create_api_token.assert_called_once() + cached = json.loads(manager._slot_path.read_text()) + assert cached["current"]["raw_key"] == "api_working_token_two" + assert cached["previous"]["name"] == "Device token abc123" + + +def test_refresh_thread_backs_off_after_failed_cycle(mocker, tmp_path): + """A failed refresh waits a short backoff instead of immediately retrying.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + mocker.patch.object(token_manager, "_utc_now", return_value=NOW + REFRESH_INTERVAL + timedelta(seconds=1)) + 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 = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + manager = _manager(mocker, tmp_path, api) + thread = Mock() + 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_initialization_surfaces_unauthorized_detail(mocker, tmp_path): + """A 401 during configured-token lookup raises with the server's response body.""" + api = Mock() + api.get_api_token_by_snippet.side_effect = UnauthorizedException( + http_resp=SimpleNamespace( + status=401, + reason="Unauthorized", + data="The API token has expired", + getheaders=lambda: {}, + ) + ) + + with pytest.raises(TokenManagerError, match="The API token has expired"): + _manager(mocker, tmp_path, api) + + +def test_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() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token abc123", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token def456", "api_working_token_one", NOW) + + _manager(mocker, tmp_path, api) + + cached = json.loads((tmp_path / f"{CONFIGURED_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 = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token("Device token abc123", "api_working_token_one", NOW) + + _manager(mocker, loose_dir, api) + + assert stat.S_IMODE(loose_dir.stat().st_mode) == TOKEN_DIR_MODE + + +def test_invalid_configured_token_cannot_escape_cache_directory(tmp_path): + """Invalid token snippets are rejected before cache paths are created.""" + configuration = Configuration(host="https://example.com/device-api") + + with pytest.raises(TokenManagerError, match="configured API token has an invalid format"): + TokenManager("../../outside-token", configuration, request_timeout=1, token_dir=tmp_path) + + +def test_refresh_interval_uses_server_ttl_and_clamps_non_positive(mocker, tmp_path): + """Refresh cadence uses server created_at/expires_at and avoids a zero/negative spin.""" + api = Mock() + api.get_api_token_by_snippet.return_value = _expiring_metadata("Device token", CONFIGURED_TOKEN) + api.create_api_token.return_value = _created_token( + "Device token abc123", + "api_working_token_one", + NOW, + created_at=NOW + timedelta(minutes=10), + expires_at=NOW + timedelta(minutes=3), + ) + manager = _manager(mocker, tmp_path, api) + + assert manager._current is not None + assert manager._refresh_interval(manager._current) == timedelta(seconds=REFRESH_RETRY_BACKOFF_SECONDS) + + stop_event = Mock() + stop_event.is_set.return_value = False + stop_event.wait.side_effect = [True] + manager._stop_event = stop_event + mocker.patch.object(manager, "refresh") + manager._run() + + assert stop_event.wait.call_args_list[0] == call(float(REFRESH_RETRY_BACKOFF_SECONDS)) + manager.refresh.assert_not_called() diff --git a/test/unit/test_token_refresh_client.py b/test/unit/test_token_refresh_client.py new file mode 100644 index 00000000..7c58c0e2 --- /dev/null +++ b/test/unit/test_token_refresh_client.py @@ -0,0 +1,20 @@ +from unittest.mock import Mock + +from groundlight.client import Groundlight + + +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()