diff --git a/generated/.openapi-generator/FILES b/generated/.openapi-generator/FILES index 27394696..6b7e6c01 100644 --- a/generated/.openapi-generator/FILES +++ b/generated/.openapi-generator/FILES @@ -6,6 +6,10 @@ docs/ActionList.md docs/ActionsApi.md docs/AllNotes.md docs/AnnotationsRequestedEnum.md +docs/ApiToken.md +docs/ApiTokenCreateResponse.md +docs/ApiTokenRequest.md +docs/ApiTokensApi.md docs/BBoxGeometry.md docs/BBoxGeometryRequest.md docs/BinaryClassificationResult.md @@ -52,6 +56,7 @@ docs/Note.md docs/NoteRequest.md docs/NotesApi.md docs/NullEnum.md +docs/PaginatedApiTokenList.md docs/PaginatedDetectorList.md docs/PaginatedImageQueryList.md docs/PaginatedMLPipelineList.md @@ -82,6 +87,7 @@ git_push.sh groundlight_openapi_client/__init__.py groundlight_openapi_client/api/__init__.py groundlight_openapi_client/api/actions_api.py +groundlight_openapi_client/api/api_tokens_api.py groundlight_openapi_client/api/detector_groups_api.py groundlight_openapi_client/api/detector_reset_api.py groundlight_openapi_client/api/detectors_api.py @@ -102,6 +108,9 @@ groundlight_openapi_client/model/action.py groundlight_openapi_client/model/action_list.py groundlight_openapi_client/model/all_notes.py groundlight_openapi_client/model/annotations_requested_enum.py +groundlight_openapi_client/model/api_token.py +groundlight_openapi_client/model/api_token_create_response.py +groundlight_openapi_client/model/api_token_request.py groundlight_openapi_client/model/b_box_geometry.py groundlight_openapi_client/model/b_box_geometry_request.py groundlight_openapi_client/model/binary_classification_result.py @@ -140,6 +149,7 @@ groundlight_openapi_client/model/multi_classification_result.py groundlight_openapi_client/model/note.py groundlight_openapi_client/model/note_request.py groundlight_openapi_client/model/null_enum.py +groundlight_openapi_client/model/paginated_api_token_list.py groundlight_openapi_client/model/paginated_detector_list.py groundlight_openapi_client/model/paginated_image_query_list.py groundlight_openapi_client/model/paginated_ml_pipeline_list.py @@ -172,4 +182,9 @@ setup.cfg setup.py test-requirements.txt test/__init__.py +test/test_api_token.py +test/test_api_token_create_response.py +test/test_api_token_request.py +test/test_api_tokens_api.py +test/test_paginated_api_token_list.py tox.ini diff --git a/generated/README.md b/generated/README.md index 40630eba..9394c1fa 100644 --- a/generated/README.md +++ b/generated/README.md @@ -120,6 +120,10 @@ Class | Method | HTTP request | Description *ActionsApi* | [**get_rule**](docs/ActionsApi.md#get_rule) | **GET** /v1/actions/rules/{id} | *ActionsApi* | [**list_detector_rules**](docs/ActionsApi.md#list_detector_rules) | **GET** /v1/actions/detector/{detector_id}/rules | *ActionsApi* | [**list_rules**](docs/ActionsApi.md#list_rules) | **GET** /v1/actions/rules | +*ApiTokensApi* | [**create_api_token**](docs/ApiTokensApi.md#create_api_token) | **POST** /v1/api-tokens | +*ApiTokensApi* | [**delete_api_token**](docs/ApiTokensApi.md#delete_api_token) | **DELETE** /v1/api-tokens/{name} | +*ApiTokensApi* | [**get_api_token_by_snippet**](docs/ApiTokensApi.md#get_api_token_by_snippet) | **GET** /v1/api-tokens/by-snippet/{snippet} | +*ApiTokensApi* | [**list_api_tokens**](docs/ApiTokensApi.md#list_api_tokens) | **GET** /v1/api-tokens | *DetectorGroupsApi* | [**create_detector_group**](docs/DetectorGroupsApi.md#create_detector_group) | **POST** /v1/detector-groups | *DetectorGroupsApi* | [**get_detector_groups**](docs/DetectorGroupsApi.md#get_detector_groups) | **GET** /v1/detector-groups | *DetectorResetApi* | [**reset_detector**](docs/DetectorResetApi.md#reset_detector) | **DELETE** /v1/detector-reset/{id} | @@ -155,6 +159,9 @@ Class | Method | HTTP request | Description - [ActionList](docs/ActionList.md) - [AllNotes](docs/AllNotes.md) - [AnnotationsRequestedEnum](docs/AnnotationsRequestedEnum.md) + - [ApiToken](docs/ApiToken.md) + - [ApiTokenCreateResponse](docs/ApiTokenCreateResponse.md) + - [ApiTokenRequest](docs/ApiTokenRequest.md) - [BBoxGeometry](docs/BBoxGeometry.md) - [BBoxGeometryRequest](docs/BBoxGeometryRequest.md) - [BinaryClassificationResult](docs/BinaryClassificationResult.md) @@ -193,6 +200,7 @@ Class | Method | HTTP request | Description - [Note](docs/Note.md) - [NoteRequest](docs/NoteRequest.md) - [NullEnum](docs/NullEnum.md) + - [PaginatedApiTokenList](docs/PaginatedApiTokenList.md) - [PaginatedDetectorList](docs/PaginatedDetectorList.md) - [PaginatedImageQueryList](docs/PaginatedImageQueryList.md) - [PaginatedMLPipelineList](docs/PaginatedMLPipelineList.md) diff --git a/generated/docs/ApiToken.md b/generated/docs/ApiToken.md new file mode 100644 index 00000000..59d9e59d --- /dev/null +++ b/generated/docs/ApiToken.md @@ -0,0 +1,16 @@ +# ApiToken + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**raw_key_snippet** | **str** | Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. | [readonly] +**created_at** | **datetime** | When was this token created? | [readonly] +**last_used_at** | **datetime** | The most recent time this API token was used. (Helpful for detecting suspicious activity). | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokenCreateResponse.md b/generated/docs/ApiTokenCreateResponse.md new file mode 100644 index 00000000..61e92d66 --- /dev/null +++ b/generated/docs/ApiTokenCreateResponse.md @@ -0,0 +1,18 @@ +# ApiTokenCreateResponse + +Response shape for token creation. Adds the raw_key, which is only ever returned once, at creation time. + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**raw_key_snippet** | **str** | Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. | [readonly] +**created_at** | **datetime** | When was this token created? | [readonly] +**last_used_at** | **datetime** | The most recent time this API token was used. (Helpful for detecting suspicious activity). | [readonly] +**raw_key** | **str** | The full API token secret. Returned only once, when the token is created. | [readonly] +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokenRequest.md b/generated/docs/ApiTokenRequest.md new file mode 100644 index 00000000..98b5a380 --- /dev/null +++ b/generated/docs/ApiTokenRequest.md @@ -0,0 +1,13 @@ +# ApiTokenRequest + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**name** | **str** | An nickname for the API token. This name must be unique for this user. | +**expires_at** | **datetime, none_type** | When does this token expire? If Null, the token never expires. | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/docs/ApiTokensApi.md b/generated/docs/ApiTokensApi.md new file mode 100644 index 00000000..cfab063f --- /dev/null +++ b/generated/docs/ApiTokensApi.md @@ -0,0 +1,325 @@ +# groundlight_openapi_client.ApiTokensApi + +All URIs are relative to *https://api.groundlight.ai/device-api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**create_api_token**](ApiTokensApi.md#create_api_token) | **POST** /v1/api-tokens | +[**delete_api_token**](ApiTokensApi.md#delete_api_token) | **DELETE** /v1/api-tokens/{name} | +[**get_api_token_by_snippet**](ApiTokensApi.md#get_api_token_by_snippet) | **GET** /v1/api-tokens/by-snippet/{snippet} | +[**list_api_tokens**](ApiTokensApi.md#list_api_tokens) | **GET** /v1/api-tokens | + + +# **create_api_token** +> ApiTokenCreateResponse create_api_token(api_token_request) + + + +Create a new API token, returning the raw_key exactly once in the response. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + api_token_request = ApiTokenRequest( + name="name_example", + expires_at=dateutil_parser('1970-01-01T00:00:00.00Z'), + ) # ApiTokenRequest | + + # example passing only required values which don't have defaults set + try: + api_response = api_instance.create_api_token(api_token_request) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->create_api_token: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **api_token_request** | [**ApiTokenRequest**](ApiTokenRequest.md)| | + +### Return type + +[**ApiTokenCreateResponse**](ApiTokenCreateResponse.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: application/json, application/x-www-form-urlencoded, multipart/form-data + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**201** | | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **delete_api_token** +> delete_api_token(name) + + + +Delete (revoke) an API token by name. The token must belong to the authenticated user. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + name = "name_example" # str | + + # example passing only required values which don't have defaults set + try: + api_instance.delete_api_token(name) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->delete_api_token: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **name** | **str**| | + +### Return type + +void (empty response body) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**204** | No response body | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **get_api_token_by_snippet** +> ApiToken get_api_token_by_snippet(snippet) + + + +Retrieve a single API token by its raw_key_snippet. Returns metadata only; the raw key is never retrievable after creation. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.api_token import ApiToken +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + snippet = "snippet_example" # str | + + # example passing only required values which don't have defaults set + try: + api_response = api_instance.get_api_token_by_snippet(snippet) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->get_api_token_by_snippet: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **snippet** | **str**| | + +### Return type + +[**ApiToken**](ApiToken.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **list_api_tokens** +> PaginatedApiTokenList list_api_tokens() + + + +List all API tokens for the authenticated user. Returns metadata only; raw keys are never retrievable after creation. + +### Example + +* Api Key Authentication (ApiToken): + +```python +import time +import groundlight_openapi_client +from groundlight_openapi_client.api import api_tokens_api +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList +from pprint import pprint +# Defining the host is optional and defaults to https://api.groundlight.ai/device-api +# See configuration.py for a list of all supported configuration parameters. +configuration = groundlight_openapi_client.Configuration( + host = "https://api.groundlight.ai/device-api" +) + +# The client must configure the authentication and authorization parameters +# in accordance with the API server security policy. +# Examples for each auth method are provided below, use the example that +# satisfies your auth use case. + +# Configure API key authorization: ApiToken +configuration.api_key['ApiToken'] = 'YOUR_API_KEY' + +# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed +# configuration.api_key_prefix['ApiToken'] = 'Bearer' + +# Enter a context with an instance of the API client +with groundlight_openapi_client.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = api_tokens_api.ApiTokensApi(api_client) + page = 1 # int | A page number within the paginated result set. (optional) + page_size = 1 # int | Number of results to return per page. (optional) + + # example passing only required values which don't have defaults set + # and optional values + try: + api_response = api_instance.list_api_tokens(page=page, page_size=page_size) + pprint(api_response) + except groundlight_openapi_client.ApiException as e: + print("Exception when calling ApiTokensApi->list_api_tokens: %s\n" % e) +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **page** | **int**| A page number within the paginated result set. | [optional] + **page_size** | **int**| Number of results to return per page. | [optional] + +### Return type + +[**PaginatedApiTokenList**](PaginatedApiTokenList.md) + +### Authorization + +[ApiToken](../README.md#ApiToken) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/generated/docs/PaginatedApiTokenList.md b/generated/docs/PaginatedApiTokenList.md new file mode 100644 index 00000000..6b83c180 --- /dev/null +++ b/generated/docs/PaginatedApiTokenList.md @@ -0,0 +1,15 @@ +# PaginatedApiTokenList + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**count** | **int** | | +**results** | [**[ApiToken]**](ApiToken.md) | | +**next** | **str, none_type** | | [optional] +**previous** | **str, none_type** | | [optional] +**any string name** | **bool, date, datetime, dict, float, int, list, str, none_type** | any string name can be used but the value must be the correct type | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/generated/groundlight_openapi_client/api/api_tokens_api.py b/generated/groundlight_openapi_client/api/api_tokens_api.py new file mode 100644 index 00000000..38720ce2 --- /dev/null +++ b/generated/groundlight_openapi_client/api/api_tokens_api.py @@ -0,0 +1,431 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.api_client import ApiClient, Endpoint as _Endpoint +from groundlight_openapi_client.model_utils import ( # noqa: F401 + check_allowed_values, + check_validations, + date, + datetime, + file_type, + none_type, + validate_and_convert_types, +) +from groundlight_openapi_client.model.api_token import ApiToken +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList + + +class ApiTokensApi(object): + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None): + if api_client is None: + api_client = ApiClient() + self.api_client = api_client + self.create_api_token_endpoint = _Endpoint( + settings={ + "response_type": (ApiTokenCreateResponse,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens", + "operation_id": "create_api_token", + "http_method": "POST", + "servers": None, + }, + params_map={ + "all": [ + "api_token_request", + ], + "required": [ + "api_token_request", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "api_token_request": (ApiTokenRequest,), + }, + "attribute_map": {}, + "location_map": { + "api_token_request": "body", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"], + }, + api_client=api_client, + ) + self.delete_api_token_endpoint = _Endpoint( + settings={ + "response_type": None, + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens/{name}", + "operation_id": "delete_api_token", + "http_method": "DELETE", + "servers": None, + }, + params_map={ + "all": [ + "name", + ], + "required": [ + "name", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "name": (str,), + }, + "attribute_map": { + "name": "name", + }, + "location_map": { + "name": "path", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": [], + "content_type": [], + }, + api_client=api_client, + ) + self.get_api_token_by_snippet_endpoint = _Endpoint( + settings={ + "response_type": (ApiToken,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens/by-snippet/{snippet}", + "operation_id": "get_api_token_by_snippet", + "http_method": "GET", + "servers": None, + }, + params_map={ + "all": [ + "snippet", + ], + "required": [ + "snippet", + ], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "snippet": (str,), + }, + "attribute_map": { + "snippet": "snippet", + }, + "location_map": { + "snippet": "path", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": [], + }, + api_client=api_client, + ) + self.list_api_tokens_endpoint = _Endpoint( + settings={ + "response_type": (PaginatedApiTokenList,), + "auth": ["ApiToken"], + "endpoint_path": "/v1/api-tokens", + "operation_id": "list_api_tokens", + "http_method": "GET", + "servers": None, + }, + params_map={ + "all": [ + "page", + "page_size", + ], + "required": [], + "nullable": [], + "enum": [], + "validation": [], + }, + root_map={ + "validations": {}, + "allowed_values": {}, + "openapi_types": { + "page": (int,), + "page_size": (int,), + }, + "attribute_map": { + "page": "page", + "page_size": "page_size", + }, + "location_map": { + "page": "query", + "page_size": "query", + }, + "collection_format_map": {}, + }, + headers_map={ + "accept": ["application/json"], + "content_type": [], + }, + api_client=api_client, + ) + + def create_api_token(self, api_token_request, **kwargs): + """create_api_token # noqa: E501 + + Create a new API token, returning the raw_key exactly once in the response. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.create_api_token(api_token_request, async_req=True) + >>> result = thread.get() + + Args: + api_token_request (ApiTokenRequest): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + ApiTokenCreateResponse + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["api_token_request"] = api_token_request + return self.create_api_token_endpoint.call_with_http_info(**kwargs) + + def delete_api_token(self, name, **kwargs): + """delete_api_token # noqa: E501 + + Delete (revoke) an API token by name. The token must belong to the authenticated user. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.delete_api_token(name, async_req=True) + >>> result = thread.get() + + Args: + name (str): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + None + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["name"] = name + return self.delete_api_token_endpoint.call_with_http_info(**kwargs) + + def get_api_token_by_snippet(self, snippet, **kwargs): + """get_api_token_by_snippet # noqa: E501 + + Retrieve a single API token by its raw_key_snippet. Returns metadata only; the raw key is never retrievable after creation. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.get_api_token_by_snippet(snippet, async_req=True) + >>> result = thread.get() + + Args: + snippet (str): + + Keyword Args: + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + ApiToken + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + kwargs["snippet"] = snippet + return self.get_api_token_by_snippet_endpoint.call_with_http_info(**kwargs) + + def list_api_tokens(self, **kwargs): + """list_api_tokens # noqa: E501 + + List all API tokens for the authenticated user. Returns metadata only; raw keys are never retrievable after creation. # noqa: E501 + This method makes a synchronous HTTP request by default. To make an + asynchronous HTTP request, please pass async_req=True + + >>> thread = api.list_api_tokens(async_req=True) + >>> result = thread.get() + + + Keyword Args: + page (int): A page number within the paginated result set.. [optional] + page_size (int): Number of results to return per page.. [optional] + _return_http_data_only (bool): response data without head status + code and headers. Default is True. + _preload_content (bool): if False, the urllib3.HTTPResponse object + will be returned without reading/decoding response data. + Default is True. + _request_timeout (int/float/tuple): timeout setting for this request. If + one number provided, it will be total request timeout. It can also + be a pair (tuple) of (connection, read) timeouts. + Default is None. + _check_input_type (bool): specifies if type checking + should be done one the data sent to the server. + Default is True. + _check_return_type (bool): specifies if type checking + should be done one the data received from the server. + Default is True. + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _content_type (str/None): force body content-type. + Default is None and content-type will be predicted by allowed + content-types and body. + _host_index (int/None): specifies the index of the server + that we want to use. + Default is read from the configuration. + async_req (bool): execute request asynchronously + + Returns: + PaginatedApiTokenList + If the method is called asynchronously, returns the request + thread. + """ + kwargs["async_req"] = kwargs.get("async_req", False) + kwargs["_return_http_data_only"] = kwargs.get("_return_http_data_only", True) + kwargs["_preload_content"] = kwargs.get("_preload_content", True) + kwargs["_request_timeout"] = kwargs.get("_request_timeout", None) + kwargs["_check_input_type"] = kwargs.get("_check_input_type", True) + kwargs["_check_return_type"] = kwargs.get("_check_return_type", True) + kwargs["_spec_property_naming"] = kwargs.get("_spec_property_naming", False) + kwargs["_content_type"] = kwargs.get("_content_type") + kwargs["_host_index"] = kwargs.get("_host_index") + return self.list_api_tokens_endpoint.call_with_http_info(**kwargs) diff --git a/generated/groundlight_openapi_client/apis/__init__.py b/generated/groundlight_openapi_client/apis/__init__.py index 4d24cda6..e19656e3 100644 --- a/generated/groundlight_openapi_client/apis/__init__.py +++ b/generated/groundlight_openapi_client/apis/__init__.py @@ -14,6 +14,7 @@ # Import APIs into API package: from groundlight_openapi_client.api.actions_api import ActionsApi +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi from groundlight_openapi_client.api.detector_groups_api import DetectorGroupsApi from groundlight_openapi_client.api.detector_reset_api import DetectorResetApi from groundlight_openapi_client.api.detectors_api import DetectorsApi diff --git a/generated/groundlight_openapi_client/model/api_token.py b/generated/groundlight_openapi_client/model/api_token.py new file mode 100644 index 00000000..a77ec253 --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token.py @@ -0,0 +1,300 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiToken(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "raw_key_snippet": (str,), # noqa: E501 + "created_at": (datetime,), # noqa: E501 + "last_used_at": (datetime,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "raw_key_snippet": "raw_key_snippet", # noqa: E501 + "created_at": "created_at", # noqa: E501 + "last_used_at": "last_used_at", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = { + "raw_key_snippet", # noqa: E501 + "created_at", # noqa: E501 + "last_used_at", # noqa: E501 + } + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, name, raw_key_snippet, created_at, last_used_at, *args, **kwargs): # noqa: E501 + """ApiToken - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + raw_key_snippet (str): Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. + created_at (datetime): When was this token created? + last_used_at (datetime): The most recent time this API token was used. (Helpful for detecting suspicious activity). + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + self.raw_key_snippet = raw_key_snippet + self.created_at = created_at + self.last_used_at = last_used_at + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiToken - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/api_token_create_response.py b/generated/groundlight_openapi_client/model/api_token_create_response.py new file mode 100644 index 00000000..1857d532 --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token_create_response.py @@ -0,0 +1,307 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiTokenCreateResponse(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "raw_key_snippet": (str,), # noqa: E501 + "created_at": (datetime,), # noqa: E501 + "last_used_at": (datetime,), # noqa: E501 + "raw_key": (str,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "raw_key_snippet": "raw_key_snippet", # noqa: E501 + "created_at": "created_at", # noqa: E501 + "last_used_at": "last_used_at", # noqa: E501 + "raw_key": "raw_key", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = { + "raw_key_snippet", # noqa: E501 + "created_at", # noqa: E501 + "last_used_at", # noqa: E501 + "raw_key", # noqa: E501 + } + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data( + cls, name, raw_key_snippet, created_at, last_used_at, raw_key, *args, **kwargs + ): # noqa: E501 + """ApiTokenCreateResponse - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + raw_key_snippet (str): Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token. + created_at (datetime): When was this token created? + last_used_at (datetime): The most recent time this API token was used. (Helpful for detecting suspicious activity). + raw_key (str): The full API token secret. Returned only once, when the token is created. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + self.raw_key_snippet = raw_key_snippet + self.created_at = created_at + self.last_used_at = last_used_at + self.raw_key = raw_key + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiTokenCreateResponse - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/api_token_request.py b/generated/groundlight_openapi_client/model/api_token_request.py new file mode 100644 index 00000000..702cdcaa --- /dev/null +++ b/generated/groundlight_openapi_client/model/api_token_request.py @@ -0,0 +1,286 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +class ApiTokenRequest(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = { + ("name",): { + "max_length": 64, + "min_length": 1, + }, + } + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + return { + "name": (str,), # noqa: E501 + "expires_at": ( + datetime, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "name": "name", # noqa: E501 + "expires_at": "expires_at", # noqa: E501 + } + + read_only_vars = {} + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, name, *args, **kwargs): # noqa: E501 + """ApiTokenRequest - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, name, *args, **kwargs): # noqa: E501 + """ApiTokenRequest - a model defined in OpenAPI + + Args: + name (str): An nickname for the API token. This name must be unique for this user. + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + expires_at (datetime, none_type): When does this token expire? If Null, the token never expires.. [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.name = name + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/model/paginated_api_token_list.py b/generated/groundlight_openapi_client/model/paginated_api_token_list.py new file mode 100644 index 00000000..57f352dc --- /dev/null +++ b/generated/groundlight_openapi_client/model/paginated_api_token_list.py @@ -0,0 +1,302 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import re # noqa: F401 +import sys # noqa: F401 + +from groundlight_openapi_client.model_utils import ( # noqa: F401 + ApiTypeError, + ModelComposed, + ModelNormal, + ModelSimple, + cached_property, + change_keys_js_to_python, + convert_js_args_to_python_args, + date, + datetime, + file_type, + none_type, + validate_get_composed_info, + OpenApiModel, +) +from groundlight_openapi_client.exceptions import ApiAttributeError + + +def lazy_import(): + from groundlight_openapi_client.model.api_token import ApiToken + + globals()["ApiToken"] = ApiToken + + +class PaginatedApiTokenList(ModelNormal): + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + + Attributes: + allowed_values (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + with a capitalized key describing the allowed value and an allowed + value. These dicts store the allowed enum values. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + discriminator_value_class_map (dict): A dict to go from the discriminator + variable value to the discriminator class name. + validations (dict): The key is the tuple path to the attribute + and the for var_name this is (var_name,). The value is a dict + that stores validations for max_length, min_length, max_items, + min_items, exclusive_maximum, inclusive_maximum, exclusive_minimum, + inclusive_minimum, and regex. + additional_properties_type (tuple): A tuple of classes accepted + as additional properties values. + """ + + allowed_values = {} + + validations = {} + + @cached_property + def additional_properties_type(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + """ + lazy_import() + return ( + bool, + date, + datetime, + dict, + float, + int, + list, + str, + none_type, + ) # noqa: E501 + + _nullable = False + + @cached_property + def openapi_types(): + """ + This must be a method because a model may have properties that are + of type self, this must run after the class is loaded + + Returns + openapi_types (dict): The key is attribute name + and the value is attribute type. + """ + lazy_import() + return { + "count": (int,), # noqa: E501 + "results": ([ApiToken],), # noqa: E501 + "next": ( + str, + none_type, + ), # noqa: E501 + "previous": ( + str, + none_type, + ), # noqa: E501 + } + + @cached_property + def discriminator(): + return None + + attribute_map = { + "count": "count", # noqa: E501 + "results": "results", # noqa: E501 + "next": "next", # noqa: E501 + "previous": "previous", # noqa: E501 + } + + read_only_vars = {} + + _composed_schemas = {} + + @classmethod + @convert_js_args_to_python_args + def _from_openapi_data(cls, count, results, *args, **kwargs): # noqa: E501 + """PaginatedApiTokenList - a model defined in OpenAPI + + Args: + count (int): + results ([ApiToken]): + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + next (str, none_type): [optional] # noqa: E501 + previous (str, none_type): [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + self = super(OpenApiModel, cls).__new__(cls) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.count = count + self.results = results + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + return self + + required_properties = set([ + "_data_store", + "_check_type", + "_spec_property_naming", + "_path_to_item", + "_configuration", + "_visited_composed_classes", + ]) + + @convert_js_args_to_python_args + def __init__(self, count, results, *args, **kwargs): # noqa: E501 + """PaginatedApiTokenList - a model defined in OpenAPI + + Args: + count (int): + results ([ApiToken]): + + Keyword Args: + _check_type (bool): if True, values for parameters in openapi_types + will be type checked and a TypeError will be + raised if the wrong type is input. + Defaults to True + _path_to_item (tuple/list): This is a list of keys or values to + drill down to the model in received_data + when deserializing a response + _spec_property_naming (bool): True if the variable names in the input data + are serialized names, as specified in the OpenAPI document. + False if the variable names in the input data + are pythonic names, e.g. snake case (default) + _configuration (Configuration): the instance to use when + deserializing a file_type parameter. + If passed, type conversion is attempted + If omitted no type conversion is done. + _visited_composed_classes (tuple): This stores a tuple of + classes that we have traveled through so that + if we see that class again we will not use its + discriminator again. + When traveling through a discriminator, the + composed schema that is + is traveled through is added to this set. + For example if Animal has a discriminator + petType and we pass in "Dog", and the class Dog + allOf includes Animal, we move through Animal + once using the discriminator, and pick Dog. + Then in Dog, we will make an instance of the + Animal class but this time we won't travel + through its discriminator because we passed in + _visited_composed_classes = (Animal,) + next (str, none_type): [optional] # noqa: E501 + previous (str, none_type): [optional] # noqa: E501 + """ + + _check_type = kwargs.pop("_check_type", True) + _spec_property_naming = kwargs.pop("_spec_property_naming", False) + _path_to_item = kwargs.pop("_path_to_item", ()) + _configuration = kwargs.pop("_configuration", None) + _visited_composed_classes = kwargs.pop("_visited_composed_classes", ()) + + if args: + raise ApiTypeError( + "Invalid positional arguments=%s passed to %s. Remove those invalid positional arguments." + % ( + args, + self.__class__.__name__, + ), + path_to_item=_path_to_item, + valid_classes=(self.__class__,), + ) + + self._data_store = {} + self._check_type = _check_type + self._spec_property_naming = _spec_property_naming + self._path_to_item = _path_to_item + self._configuration = _configuration + self._visited_composed_classes = _visited_composed_classes + (self.__class__,) + + self.count = count + self.results = results + for var_name, var_value in kwargs.items(): + if ( + var_name not in self.attribute_map + and self._configuration is not None + and self._configuration.discard_unknown_keys + and self.additional_properties_type is None + ): + # discard variable. + continue + setattr(self, var_name, var_value) + if var_name in self.read_only_vars: + raise ApiAttributeError( + f"`{var_name}` is a read-only attribute. Use `from_openapi_data` to instantiate " + "class with read only attributes." + ) diff --git a/generated/groundlight_openapi_client/models/__init__.py b/generated/groundlight_openapi_client/models/__init__.py index f35cb11e..94b00c6a 100644 --- a/generated/groundlight_openapi_client/models/__init__.py +++ b/generated/groundlight_openapi_client/models/__init__.py @@ -14,6 +14,9 @@ from groundlight_openapi_client.model.action_list import ActionList from groundlight_openapi_client.model.all_notes import AllNotes from groundlight_openapi_client.model.annotations_requested_enum import AnnotationsRequestedEnum +from groundlight_openapi_client.model.api_token import ApiToken +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest from groundlight_openapi_client.model.b_box_geometry import BBoxGeometry from groundlight_openapi_client.model.b_box_geometry_request import BBoxGeometryRequest from groundlight_openapi_client.model.binary_classification_result import BinaryClassificationResult @@ -52,6 +55,7 @@ from groundlight_openapi_client.model.note import Note from groundlight_openapi_client.model.note_request import NoteRequest from groundlight_openapi_client.model.null_enum import NullEnum +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList from groundlight_openapi_client.model.paginated_detector_list import PaginatedDetectorList from groundlight_openapi_client.model.paginated_image_query_list import PaginatedImageQueryList from groundlight_openapi_client.model.paginated_ml_pipeline_list import PaginatedMLPipelineList diff --git a/generated/model.py b/generated/model.py index a9c0f7f3..47abb633 100644 --- a/generated/model.py +++ b/generated/model.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: public-api.yaml -# timestamp: 2026-06-16T00:34:34+00:00 +# timestamp: 2026-07-09T18:24:59+00:00 from __future__ import annotations @@ -28,6 +28,53 @@ class AccountMonthToDateInfo(BaseModel): ) +class ApiToken(BaseModel): + name: constr(max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + raw_key_snippet: str = Field( + ..., description="Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token." + ) + created_at: datetime = Field(..., description="When was this token created?") + last_used_at: datetime = Field( + ..., description="The most recent time this API token was used. (Helpful for detecting suspicious activity)." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + + +class ApiTokenCreateResponse(BaseModel): + """ + Response shape for token creation. Adds the raw_key, which is only ever returned once, + at creation time. + """ + + name: constr(max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + raw_key_snippet: str = Field( + ..., description="Since we're storing hashed keys, it can be useful to see the raw prefix snippet of the token." + ) + created_at: datetime = Field(..., description="When was this token created?") + last_used_at: datetime = Field( + ..., description="The most recent time this API token was used. (Helpful for detecting suspicious activity)." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + raw_key: str = Field(..., description="The full API token secret. Returned only once, when the token is created.") + + +class ApiTokenRequest(BaseModel): + name: constr(min_length=1, max_length=64) = Field( + ..., description="An nickname for the API token. This name must be unique for this user." + ) + expires_at: Optional[datetime] = Field( + None, description="When does this token expire? If Null, the token never expires." + ) + + class BBoxGeometry(BaseModel): """ Mixin for serializers to handle data in the StrictBaseModel format @@ -178,6 +225,13 @@ class NullEnum(Enum): NoneType_None = None +class PaginatedApiTokenList(BaseModel): + count: int = Field(..., examples=[123]) + next: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=4"]) + previous: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=2"]) + results: List[ApiToken] + + class PaginatedMLPipelineList(BaseModel): count: int = Field(..., examples=[123]) next: Optional[AnyUrl] = Field(None, examples=["http://api.example.org/accounts/?page=4"]) diff --git a/generated/test/test_api_token.py b/generated/test/test_api_token.py new file mode 100644 index 00000000..83ca5f3b --- /dev/null +++ b/generated/test/test_api_token.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token import ApiToken + + +class TestApiToken(unittest.TestCase): + """ApiToken unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiToken(self): + """Test ApiToken""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiToken() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_token_create_response.py b/generated/test/test_api_token_create_response.py new file mode 100644 index 00000000..a5153874 --- /dev/null +++ b/generated/test/test_api_token_create_response.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token_create_response import ApiTokenCreateResponse + + +class TestApiTokenCreateResponse(unittest.TestCase): + """ApiTokenCreateResponse unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiTokenCreateResponse(self): + """Test ApiTokenCreateResponse""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiTokenCreateResponse() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_token_request.py b/generated/test/test_api_token_request.py new file mode 100644 index 00000000..3a150751 --- /dev/null +++ b/generated/test/test_api_token_request.py @@ -0,0 +1,35 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest + + +class TestApiTokenRequest(unittest.TestCase): + """ApiTokenRequest unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testApiTokenRequest(self): + """Test ApiTokenRequest""" + # FIXME: construct object with mandatory attributes with example values + # model = ApiTokenRequest() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_api_tokens_api.py b/generated/test/test_api_tokens_api.py new file mode 100644 index 00000000..780b61f9 --- /dev/null +++ b/generated/test/test_api_tokens_api.py @@ -0,0 +1,44 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi # noqa: E501 + + +class TestApiTokensApi(unittest.TestCase): + """ApiTokensApi unit test stubs""" + + def setUp(self): + self.api = ApiTokensApi() # noqa: E501 + + def tearDown(self): + pass + + def test_create_api_token(self): + """Test case for create_api_token""" + pass + + def test_delete_api_token(self): + """Test case for delete_api_token""" + pass + + def test_get_api_token_by_snippet(self): + """Test case for get_api_token_by_snippet""" + pass + + def test_list_api_tokens(self): + """Test case for list_api_tokens""" + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/generated/test/test_paginated_api_token_list.py b/generated/test/test_paginated_api_token_list.py new file mode 100644 index 00000000..b5cbd754 --- /dev/null +++ b/generated/test/test_paginated_api_token_list.py @@ -0,0 +1,38 @@ +""" + Groundlight API + + Groundlight makes it simple to understand images. You can easily create computer vision detectors just by describing what you want to know using natural language. # noqa: E501 + + The version of the OpenAPI document: 0.18.2 + Contact: support@groundlight.ai + Generated by: https://openapi-generator.tech +""" + +import sys +import unittest + +import groundlight_openapi_client +from groundlight_openapi_client.model.api_token import ApiToken + +globals()["ApiToken"] = ApiToken +from groundlight_openapi_client.model.paginated_api_token_list import PaginatedApiTokenList + + +class TestPaginatedApiTokenList(unittest.TestCase): + """PaginatedApiTokenList unit test stubs""" + + def setUp(self): + pass + + def tearDown(self): + pass + + def testPaginatedApiTokenList(self): + """Test PaginatedApiTokenList""" + # FIXME: construct object with mandatory attributes with example values + # model = PaginatedApiTokenList() # noqa: E501 + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/package-lock.json b/package-lock.json index cbc8a40b..c5bd438b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.21.tgz", "integrity": "sha512-YV1HYDGsm2rnR0vrLKidtrG6jYX5yqiIjeur1j8++dKGqhhsJ6cjMs0RfQRSTUH7IjgDemA59/znQ8nRrE0D9g==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -409,7 +408,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -2281,15 +2279,13 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/rehype-katex": { "version": "7.0.1", @@ -2349,7 +2345,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } diff --git a/pyproject.toml b/pyproject.toml index a62070be..ce7ddb53 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.12.0,<3.17.0" +platformdirs = ">=3.0.0,<4.0.0" [tool.poetry.group.dev.dependencies] datamodel-code-generator = "^0.35.0" diff --git a/spec/public-api.yaml b/spec/public-api.yaml index 494c1f2f..932887ad 100644 --- a/spec/public-api.yaml +++ b/spec/public-api.yaml @@ -144,6 +144,102 @@ paths: responses: '204': description: No response body + /v1/api-tokens: + get: + operationId: List API tokens + description: List all API tokens for the authenticated user. Returns metadata + only; raw keys are never retrievable after creation. + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedApiTokenList' + description: '' + post: + operationId: Create API token + description: Create a new API token, returning the raw_key exactly once in the + response. + tags: + - api-tokens + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/ApiTokenRequest' + required: true + security: + - ApiToken: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ApiTokenCreateResponse' + description: '' + /v1/api-tokens/{name}: + delete: + operationId: Delete API token + description: Delete (revoke) an API token by name. The token must belong to + the authenticated user. + parameters: + - in: path + name: name + schema: + type: string + required: true + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '204': + description: No response body + /v1/api-tokens/by-snippet/{snippet}: + get: + operationId: Get API token by snippet + description: Retrieve a single API token by its raw_key_snippet. Returns metadata + only; the raw key is never retrievable after creation. + parameters: + - in: path + name: snippet + schema: + type: string + required: true + tags: + - api-tokens + security: + - ApiToken: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ApiToken' + description: '' /v1/detector-groups: get: operationId: Get Detector Groups @@ -1015,6 +1111,99 @@ components: required: - CUSTOMER - GL + ApiToken: + type: object + properties: + name: + type: string + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + raw_key_snippet: + type: string + readOnly: true + description: Since we're storing hashed keys, it can be useful to see the + raw prefix snippet of the token. + created_at: + type: string + format: date-time + readOnly: true + description: When was this token created? + last_used_at: + type: string + format: date-time + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + required: + - created_at + - last_used_at + - name + - raw_key_snippet + ApiTokenCreateResponse: + type: object + description: |- + Response shape for token creation. Adds the raw_key, which is only ever returned once, + at creation time. + properties: + name: + type: string + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + raw_key_snippet: + type: string + readOnly: true + description: Since we're storing hashed keys, it can be useful to see the + raw prefix snippet of the token. + created_at: + type: string + format: date-time + readOnly: true + description: When was this token created? + last_used_at: + type: string + format: date-time + readOnly: true + description: The most recent time this API token was used. (Helpful for + detecting suspicious activity). + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + raw_key: + type: string + readOnly: true + description: The full API token secret. Returned only once, when the token + is created. + required: + - created_at + - last_used_at + - name + - raw_key + - raw_key_snippet + ApiTokenRequest: + type: object + properties: + name: + type: string + minLength: 1 + description: An nickname for the API token. This name must be unique for + this user. + maxLength: 64 + expires_at: + type: string + format: date-time + nullable: true + description: When does this token expire? If Null, the token never expires. + required: + - name BBoxGeometry: type: object description: Mixin for serializers to handle data in the StrictBaseModel format @@ -1615,6 +1804,29 @@ components: NullEnum: enum: - null + PaginatedApiTokenList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ApiToken' PaginatedDetectorList: type: object required: diff --git a/src/groundlight/cli.py b/src/groundlight/cli.py index 82fa5fa9..a90fca83 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 helper for the SDK client; not useful as a shell command "create_roi", # returns an ROI object that must be passed to another API call; not useful standalone "get_raw_headers", # returns the API token in plaintext "make_generic_api_request", diff --git a/src/groundlight/client.py b/src/groundlight/client.py index edcb8771..ba69b1a4 100644 --- a/src/groundlight/client.py +++ b/src/groundlight/client.py @@ -8,6 +8,7 @@ from typing import Any, Callable, List, Optional, Tuple, Union from groundlight_openapi_client import Configuration +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi from groundlight_openapi_client.api.detector_groups_api import DetectorGroupsApi from groundlight_openapi_client.api.detectors_api import DetectorsApi from groundlight_openapi_client.api.image_queries_api import ImageQueriesApi @@ -52,6 +53,7 @@ sanitize_endpoint_url, ) from groundlight.optional_imports import Image, np +from groundlight.token_manager import TokenManager logger = logging.getLogger("groundlight.sdk") @@ -79,7 +81,8 @@ class Groundlight: # pylint: disable=too-many-instance-attributes,too-many-publ submit images for analysis, and retrieve predictions. The API token (auth) is specified through the **GROUNDLIGHT_API_TOKEN** environment variable by - default. + default. That value is treated as a bootstrap credential: the client mints a short-lived working + token, caches it under the token directory, and refreshes it in the background. If you are using a Groundlight Edge device, you can specify the endpoint through the **GROUNDLIGHT_ENDPOINT** environment variable. @@ -173,6 +176,7 @@ def __init__( raise ApiTokenError(API_TOKEN_MISSING_HELP_MESSAGE) from e if not api_token: raise ApiTokenError("No API token found. GROUNDLIGHT_API_TOKEN environment variable is set but blank") + self._bootstrap_token = api_token self.api_token_prefix = api_token[:12] should_disable_tls_verification = disable_tls_verification @@ -200,6 +204,14 @@ def __init__( self.user_api = UserApi(self.api_client) self.labels_api = LabelsApi(self.api_client) self.month_to_date_api = MonthToDateAccountInfoApi(self.api_client) + self.api_tokens_api = ApiTokensApi(self.api_client) + self._token_manager = TokenManager( + bootstrap_token=self._bootstrap_token, + api_tokens_api=self.api_tokens_api, + set_api_token=self._set_api_token, + ) + self.api_client.token_manager = self._token_manager + self.api_token_prefix = self._token_manager.working_token[:12] self.logged_in_user = "(not-logged-in)" self._verify_connectivity() @@ -207,6 +219,22 @@ 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 self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def close(self) -> None: + """Stop background token refresh. Safe to call more than once.""" + if self._token_manager is not None: + self._token_manager.close() + + def _set_api_token(self, api_token: str) -> None: + """Install the working API token used for subsequent requests.""" + self.configuration.api_key["ApiToken"] = api_token + self.api_token_prefix = api_token[:12] + def _verify_connectivity(self) -> None: """ Verify that the client can connect to the Groundlight service, and raise a helpful diff --git a/src/groundlight/config.py b/src/groundlight/config.py index c254d46d..f2a267b8 100644 --- a/src/groundlight/config.py +++ b/src/groundlight/config.py @@ -1,11 +1,18 @@ API_TOKEN_WEB_URL = "https://dashboard.groundlight.ai/reef/my-account/api-tokens" API_TOKEN_VARIABLE_NAME = "GROUNDLIGHT_API_TOKEN" +TOKEN_DIR_VARIABLE_NAME = "GROUNDLIGHT_TOKEN_DIR" DEFAULT_ENDPOINT = "https://api.groundlight.ai/" DISABLE_TLS_VARIABLE_NAME = "DISABLE_TLS_VERIFY" -__all__ = ["API_TOKEN_WEB_URL", "API_TOKEN_VARIABLE_NAME", "DEFAULT_ENDPOINT", "DISABLE_TLS_VARIABLE_NAME"] +__all__ = [ + "API_TOKEN_WEB_URL", + "API_TOKEN_VARIABLE_NAME", + "TOKEN_DIR_VARIABLE_NAME", + "DEFAULT_ENDPOINT", + "DISABLE_TLS_VARIABLE_NAME", +] API_TOKEN_MISSING_HELP_MESSAGE = ( "No API token found. Please put your token in an environment variable " diff --git a/src/groundlight/internalapi.py b/src/groundlight/internalapi.py index fd4304cb..c40abe6c 100644 --- a/src/groundlight/internalapi.py +++ b/src/groundlight/internalapi.py @@ -11,6 +11,7 @@ import requests from groundlight_openapi_client.api_client import ApiClient, ApiException +from groundlight_openapi_client.exceptions import UnauthorizedException from model import Detector, ImageQuery from groundlight.status_codes import is_ok @@ -166,12 +167,14 @@ class GroundlightApiClient(ApiClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.user_agent = f"Groundlight-Python-SDK/{get_version()}/{platform.platform()}/{platform.python_version()}" + self.token_manager = None + self._reminting_token = False REQUEST_ID_HEADER = "X-Request-Id" @RequestsRetryDecorator() def call_api(self, *args, **kwargs): - """Adds a request-id header to each API call.""" + """Adds a request-id header to each API call, and remints on 401 when token refresh is enabled.""" # Note we don't look for header_param in kwargs here, because this method is only called in one place # in the generated code, so we can afford to make this brittle. header_param = args[4] # that's the number in the list @@ -181,7 +184,20 @@ def call_api(self, *args, **kwargs): elif not header_param.get(self.REQUEST_ID_HEADER, None): header_param[self.REQUEST_ID_HEADER] = _generate_request_id() # Note that we have updated the actual dict in args, so we don't have to put it back in - return super().call_api(*args, **kwargs) + try: + return super().call_api(*args, **kwargs) + except UnauthorizedException: + if self.token_manager is None or self._reminting_token: + raise + self._reminting_token = True + try: + if not self.token_manager.remint_after_unauthorized(): + raise + if header_param is not None: + header_param[self.REQUEST_ID_HEADER] = _generate_request_id() + return super().call_api(*args, **kwargs) + finally: + self._reminting_token = False # # The methods below will eventually go away when we move to properly model diff --git a/src/groundlight/token_manager.py b/src/groundlight/token_manager.py new file mode 100644 index 00000000..822aa73c --- /dev/null +++ b/src/groundlight/token_manager.py @@ -0,0 +1,422 @@ +"""Disk-backed API token cache and background refresh for short-lived working tokens.""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Optional + +from filelock import FileLock, Timeout +from groundlight_openapi_client.api.api_tokens_api import ApiTokensApi +from groundlight_openapi_client.exceptions import NotFoundException +from groundlight_openapi_client.model.api_token_request import ApiTokenRequest +from platformdirs import user_data_dir + +from groundlight.config import TOKEN_DIR_VARIABLE_NAME + +logger = logging.getLogger("groundlight.sdk") + +TOKEN_TTL_DAYS = 30 +REFRESH_INTERVAL_DAYS = 1 +CLEANUP_GRACE_FACTOR = 2 +SNIPPET_LENGTH = 20 +NAME_MAX_LENGTH = 64 +NAME_SUFFIX_CHARS = 7 # space + 6 hex chars +LOCK_TIMEOUT_SECONDS = 5 +MINT_REQUEST_TIMEOUT_SECONDS = 10 +FALLBACK_TOKEN_NAME_PREFIX = "sdk-auto" + + +@dataclass +class CachedToken: + """One working token stored in a cache slot.""" + + raw_key: str + snippet: str + name: str + expires_at: datetime + minted_at: datetime + + def to_dict(self) -> dict[str, str]: + return { + "raw_key": self.raw_key, + "snippet": self.snippet, + "name": self.name, + "expires_at": _format_utc(self.expires_at), + "minted_at": _format_utc(self.minted_at), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CachedToken": + return cls( + raw_key=data["raw_key"], + snippet=data["snippet"], + name=data["name"], + expires_at=_parse_utc(data["expires_at"]), + minted_at=_parse_utc(data["minted_at"]), + ) + + +@dataclass +class PreviousToken: + """Metadata needed to delete a superseded token after the grace window.""" + + name: str + minted_at: datetime + + def to_dict(self) -> dict[str, str]: + return {"name": self.name, "minted_at": _format_utc(self.minted_at)} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PreviousToken": + return cls(name=data["name"], minted_at=_parse_utc(data["minted_at"])) + + +@dataclass +class TokenSlot: + """On-disk cache slot for one bootstrap token.""" + + current: CachedToken + previous: Optional[PreviousToken] = None + + def to_dict(self) -> dict[str, Any]: + return { + "current": self.current.to_dict(), + "previous": self.previous.to_dict() if self.previous else None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TokenSlot": + previous_data = data.get("previous") + return cls( + current=CachedToken.from_dict(data["current"]), + previous=PreviousToken.from_dict(previous_data) if previous_data else None, + ) + + +def token_snippet(raw_key: str) -> str: + """Return the cache-key snippet for a raw API token.""" + return raw_key[:SNIPPET_LENGTH] + + +def default_token_dir() -> Path: + """Resolve the token cache directory from env or the platform user data dir.""" + override = os.environ.get(TOKEN_DIR_VARIABLE_NAME) + if override: + return Path(override).expanduser() + return Path(user_data_dir("groundlight", appauthor=False)) / "tokens" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _format_utc(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse_utc(value: str) -> datetime: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _next_token_name(base_name: Optional[str]) -> str: + suffix = f" {secrets.token_hex(3)}" + if not base_name: + return f"{FALLBACK_TOKEN_NAME_PREFIX}{suffix}" + max_base_len = NAME_MAX_LENGTH - NAME_SUFFIX_CHARS + truncated = base_name[:max_base_len] + return f"{truncated}{suffix}" + + +class TokenManager: # pylint: disable=too-many-instance-attributes + """Manages a short-lived working token backed by a per-bootstrap-token disk cache.""" + + def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments + self, + bootstrap_token: str, + api_tokens_api: ApiTokensApi, + set_api_token: Callable[[str], None], + token_dir: Optional[Path] = None, + refresh_interval: Optional[timedelta] = None, + token_ttl: Optional[timedelta] = None, + ): + """Create a manager for one bootstrap token and start using a cached or freshly minted working token.""" + self.bootstrap_token = bootstrap_token + self.bootstrap_snippet = token_snippet(bootstrap_token) + self.api_tokens_api = api_tokens_api + self._set_api_token = set_api_token + self.token_dir = Path(token_dir) if token_dir is not None else default_token_dir() + self.refresh_interval = refresh_interval or timedelta(days=REFRESH_INTERVAL_DAYS) + self.token_ttl = token_ttl or timedelta(days=TOKEN_TTL_DAYS) + self.cleanup_grace = self.refresh_interval * CLEANUP_GRACE_FACTOR + + self._slot_path = self.token_dir / f"{self.bootstrap_snippet}.json" + self._lock_path = self.token_dir / f"{self.bootstrap_snippet}.lock" + self._lock = FileLock(str(self._lock_path), timeout=LOCK_TIMEOUT_SECONDS) + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + self._slot: Optional[TokenSlot] = None + # False when the server does not expose api-tokens endpoints yet (e.g. older prod). + self._refresh_enabled = True + + self._ensure_token_dir() + self._initialize_working_token() + if self._refresh_enabled: + self._start_refresh_thread() + + @property + def working_token(self) -> str: + """Return the raw key currently used for API calls.""" + if self._slot is None: + raise RuntimeError("TokenManager has no working token") + return self._slot.current.raw_key + + def close(self) -> None: + """Stop the background refresh thread.""" + self._stop_event.set() + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=2) + + def remint_after_unauthorized(self) -> bool: + """Re-mint using the bootstrap token after a 401 on the cached working token. + + Returns True if a new working token was installed. + """ + if not self._refresh_enabled: + return False + # Avoid deadlock if a mint/refresh already holds the lock and its HTTP call 401s. + if self._lock.is_locked: + return False + previous_working = self._slot.current.raw_key if self._slot is not None else None + try: + with self._lock: + self._mint_and_persist(auth_token=self.bootstrap_token, previous_slot=self._slot) + return True + except Timeout: + logger.warning("Could not acquire token lock while recovering from 401; skipping remint") + if previous_working is not None: + self._set_api_token(previous_working) + return False + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to remint API token after 401") + if previous_working is not None: + self._set_api_token(previous_working) + return False + + def _ensure_token_dir(self) -> None: + try: + self.token_dir.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + os.chmod(self.token_dir, 0o700) + except OSError as exc: + raise RuntimeError( + f"Unable to create token cache directory '{self.token_dir}'. " + f"Set {TOKEN_DIR_VARIABLE_NAME} to a writable path." + ) from exc + + def _initialize_working_token(self) -> None: + try: + with self._lock: + slot = self._read_slot() + if slot is not None and not self._is_expired(slot.current): + self._install_slot(slot) + return + try: + self._mint_and_persist(auth_token=self.bootstrap_token, previous_slot=slot) + except NotFoundException: + # api-tokens endpoints are not deployed on this server yet. + logger.warning( + "API token management endpoints are unavailable; " + "using the bootstrap token without auto-refresh." + ) + self._use_bootstrap_only() + except Timeout as exc: + raise RuntimeError( + f"Timed out acquiring token lock at '{self._lock_path}'. Another process may be stuck holding the lock." + ) from exc + + def _use_bootstrap_only(self) -> None: + """Fall back to the bootstrap token when short-lived token APIs are unavailable.""" + self._refresh_enabled = False + now = _utcnow() + self._slot = TokenSlot( + current=CachedToken( + raw_key=self.bootstrap_token, + snippet=self.bootstrap_snippet, + name="bootstrap", + expires_at=now + self.token_ttl, + minted_at=now, + ) + ) + self._set_api_token(self.bootstrap_token) + + def _start_refresh_thread(self) -> None: + thread_name = f"gl-token-refresh-{self.bootstrap_snippet[:8]}" + self._thread = threading.Thread(target=self._refresh_loop, name=thread_name, daemon=True) + self._thread.start() + + def _refresh_loop(self) -> None: + while not self._stop_event.is_set(): + wait_seconds = self._seconds_until_refresh() + if self._stop_event.wait(timeout=wait_seconds): + return + try: + self._refresh_once() + except Exception: # pylint: disable=broad-exception-caught + logger.warning("API token refresh failed; will retry on the next cycle", exc_info=True) + + def _seconds_until_refresh(self) -> float: + if self._slot is None: + return 0.0 + due_at = self._slot.current.minted_at + self.refresh_interval + return max(0.0, (due_at - _utcnow()).total_seconds()) + + def _refresh_once(self) -> None: + try: + with self._lock: + slot = self._read_slot() or self._slot + if slot is None: + self._mint_and_persist(auth_token=self.bootstrap_token, previous_slot=None) + return + if _utcnow() < slot.current.minted_at + self.refresh_interval: + self._install_slot(slot) + return + self._cleanup_previous(slot) + self._mint_and_persist(auth_token=slot.current.raw_key, previous_slot=slot) + except Timeout: + logger.warning("Could not acquire token lock for refresh; skipping this cycle") + + def _cleanup_previous(self, slot: TokenSlot) -> None: + previous = slot.previous + if previous is None: + return + if _utcnow() < previous.minted_at + self.cleanup_grace: + return + try: + self.api_tokens_api.delete_api_token( + previous.name, + _request_timeout=MINT_REQUEST_TIMEOUT_SECONDS, + ) + except NotFoundException: + logger.debug("Previous API token '%s' already deleted", previous.name) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Failed to delete previous API token '%s'", previous.name, exc_info=True) + return + slot.previous = None + self._write_slot(slot) + + def _mint_and_persist(self, auth_token: str, previous_slot: Optional[TokenSlot]) -> None: + self._set_api_token(auth_token) + current_name = self._lookup_token_name(token_snippet(auth_token)) + new_name = _next_token_name(current_name) + expires_at = _utcnow() + self.token_ttl + created = self.api_tokens_api.create_api_token( + ApiTokenRequest(name=new_name, expires_at=expires_at), + _request_timeout=MINT_REQUEST_TIMEOUT_SECONDS, + ) + minted_at = _utcnow() + new_current = CachedToken( + raw_key=created.raw_key, + snippet=created.raw_key_snippet or token_snippet(created.raw_key), + name=created.name, + expires_at=created.expires_at or expires_at, + minted_at=minted_at, + ) + previous = None + if previous_slot is not None: + previous = PreviousToken(name=previous_slot.current.name, minted_at=previous_slot.current.minted_at) + slot = TokenSlot(current=new_current, previous=previous) + self._write_slot(slot) + self._install_slot(slot) + + def _lookup_token_name(self, snippet: str) -> Optional[str]: + """Find the name of the token matching ``snippet``. + + TODO: This temporarily pages through List API tokens and matches on + raw_key_snippet. Before merging, switch callers to + ``_get_token_name_by_snippet`` (GET /v1/api-tokens/by-snippet/{snippet}) + once that endpoint is live in production. + """ + page = 1 + while True: + response = self.api_tokens_api.list_api_tokens( + page=page, + page_size=100, + _request_timeout=MINT_REQUEST_TIMEOUT_SECONDS, + ) + results = response.results or [] + for token in results: + if token.raw_key_snippet == snippet: + return token.name + if not response.next: + return None + page += 1 + + def _get_token_name_by_snippet(self, snippet: str) -> Optional[str]: + """Look up a token name via GET /v1/api-tokens/by-snippet/{snippet}. + + TODO: Switch ``_lookup_token_name`` to call this before merging, once the + by-snippet endpoint from zuuul PR #6579 is deployed. Do not call this yet; + the endpoint is not live. + """ + try: + token = self.api_tokens_api.get_api_token_by_snippet( + snippet, + _request_timeout=MINT_REQUEST_TIMEOUT_SECONDS, + ) + except NotFoundException: + return None + return token.name + + def _install_slot(self, slot: TokenSlot) -> None: + self._slot = slot + self._set_api_token(slot.current.raw_key) + + def _is_expired(self, token: CachedToken) -> bool: + expires_at = token.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return _utcnow() >= expires_at.astimezone(timezone.utc) + + def _read_slot(self) -> Optional[TokenSlot]: + if not self._slot_path.exists(): + return None + try: + data = json.loads(self._slot_path.read_text(encoding="utf-8")) + return TokenSlot.from_dict(data) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): + logger.warning("Ignoring unreadable token cache file at %s", self._slot_path, exc_info=True) + return None + + def _write_slot(self, slot: TokenSlot) -> None: + payload = json.dumps(slot.to_dict(), indent=2, sort_keys=True) + temp_path = self._slot_path.with_suffix(".tmp") + try: + fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, self._slot_path) + if os.name != "nt": + os.chmod(self._slot_path, 0o600) + except OSError as exc: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + raise RuntimeError(f"Unable to write token cache file '{self._slot_path}'") from exc diff --git a/test/unit/test_token_manager.py b/test/unit/test_token_manager.py new file mode 100644 index 00000000..5b98f2dd --- /dev/null +++ b/test/unit/test_token_manager.py @@ -0,0 +1,265 @@ +"""Unit tests for disk-backed API token refresh.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Optional +from unittest.mock import MagicMock + +import pytest +from groundlight.token_manager import ( + TOKEN_TTL_DAYS, + CachedToken, + PreviousToken, + TokenManager, + TokenSlot, + _next_token_name, + token_snippet, +) +from groundlight_openapi_client.exceptions import NotFoundException + + +def _utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> datetime: + return datetime(year, month, day, hour, minute, tzinfo=timezone.utc) + + +class FakeApiTokensApi: + """In-memory stand-in for ApiTokensApi.""" + + def __init__(self) -> None: + self.tokens: list[dict[str, Any]] = [] + self.deleted: list[str] = [] + self.create_calls = 0 + self.list_calls = 0 + self.by_snippet_calls = 0 + + def seed(self, name: str, raw_key: str, expires_at: Optional[datetime] = None) -> None: + self.tokens.append({ + "name": name, + "raw_key": raw_key, + "raw_key_snippet": token_snippet(raw_key), + "expires_at": expires_at or (_utc(2099, 1, 1)), + }) + + def list_api_tokens(self, page: int = 1, page_size: int = 100, **kwargs): # pylint: disable=unused-argument + self.list_calls += 1 + start = (page - 1) * page_size + end = start + page_size + page_tokens = self.tokens[start:end] + response = MagicMock() + response.results = [MagicMock(**{k: v for k, v in t.items() if k != "raw_key"}) for t in page_tokens] + for mock_token, raw in zip(response.results, page_tokens): + mock_token.raw_key_snippet = raw["raw_key_snippet"] + mock_token.name = raw["name"] + response.next = "next" if end < len(self.tokens) else None + return response + + def create_api_token(self, api_token_request, **kwargs): # pylint: disable=unused-argument + self.create_calls += 1 + raw_key = f"api_minted_{self.create_calls:04d}_{'x' * 40}" + token = { + "name": api_token_request.name, + "raw_key": raw_key, + "raw_key_snippet": token_snippet(raw_key), + "expires_at": api_token_request.expires_at, + } + self.tokens.append(token) + created = MagicMock() + created.name = token["name"] + created.raw_key = token["raw_key"] + created.raw_key_snippet = token["raw_key_snippet"] + created.expires_at = token["expires_at"] + return created + + def delete_api_token(self, name: str, **kwargs): # pylint: disable=unused-argument + self.deleted.append(name) + self.tokens = [t for t in self.tokens if t["name"] != name] + + def get_api_token_by_snippet(self, snippet: str, **kwargs): # pylint: disable=unused-argument + self.by_snippet_calls += 1 + for token in self.tokens: + if token["raw_key_snippet"] == snippet: + found = MagicMock() + found.name = token["name"] + found.raw_key_snippet = token["raw_key_snippet"] + return found + raise NotFoundException(status=404, reason="Not found") + + +@pytest.fixture(name="bootstrap") +def fixture_bootstrap() -> str: + return "api_bootstrap_token_abcdefghijklmnop" + + +@pytest.fixture(name="api") +def fixture_api(bootstrap: str) -> FakeApiTokensApi: + api = FakeApiTokensApi() + api.seed("Bootstrap Token", bootstrap) + return api + + +def test_token_snippet_uses_first_20_chars(bootstrap: str): + assert token_snippet(bootstrap) == bootstrap[:20] + assert len(token_snippet(bootstrap)) == 20 # noqa: PLR2004 + + +def test_next_token_name_truncates_long_base(): + long_name = "A" * 64 + result = _next_token_name(long_name) + assert len(result) == 64 # noqa: PLR2004 + assert result.startswith("A" * 57) + assert result[57] == " " + + +def test_next_token_name_fallback_when_missing(): + result = _next_token_name(None) + assert result.startswith("sdk-auto ") + assert len(result) == len("sdk-auto ") + 6 + + +def test_manager_mints_when_cache_missing(tmp_path, bootstrap: str, api: FakeApiTokensApi): + installed: list[str] = [] + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=installed.append, + token_dir=tmp_path, + refresh_interval=timedelta(days=30), + token_ttl=timedelta(days=TOKEN_TTL_DAYS), + ) + try: + assert api.create_calls == 1 + assert api.list_calls >= 1 + assert api.by_snippet_calls == 0 + assert manager.working_token.startswith("api_minted_") + assert installed[-1] == manager.working_token + slot_path = tmp_path / f"{token_snippet(bootstrap)}.json" + assert slot_path.exists() + data = json.loads(slot_path.read_text(encoding="utf-8")) + assert data["current"]["raw_key"] == manager.working_token + assert data["previous"] is None + finally: + manager.close() + + +def test_manager_reuses_valid_cache(tmp_path, bootstrap: str, api: FakeApiTokensApi): + snippet = token_snippet(bootstrap) + cached_key = "api_cached_token_1234567890abcdef" + now = datetime.now(timezone.utc) + slot = TokenSlot( + current=CachedToken( + raw_key=cached_key, + snippet=token_snippet(cached_key), + name="Cached Token", + expires_at=now + timedelta(days=TOKEN_TTL_DAYS), + minted_at=now, + ) + ) + (tmp_path / f"{snippet}.json").write_text(json.dumps(slot.to_dict()), encoding="utf-8") + + installed: list[str] = [] + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=installed.append, + token_dir=tmp_path, + refresh_interval=timedelta(days=30), + ) + try: + assert api.create_calls == 0 + assert manager.working_token == cached_key + assert installed[-1] == cached_key + finally: + manager.close() + + +def test_refresh_mints_and_promotes_previous(tmp_path, bootstrap: str, api: FakeApiTokensApi): + installed: list[str] = [] + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=installed.append, + token_dir=tmp_path, + refresh_interval=timedelta(days=1), + ) + try: + first = manager.working_token + slot = manager._slot # pylint: disable=protected-access + assert slot is not None + first_name = slot.current.name + # Make the current token look overdue for refresh. + slot.current.minted_at = _utc(2020, 1, 1) + manager._write_slot(slot) # pylint: disable=protected-access + manager._refresh_once() # pylint: disable=protected-access + assert manager.working_token != first + updated = manager._slot # pylint: disable=protected-access + assert updated is not None + assert updated.previous is not None + assert updated.previous.name == first_name + finally: + manager.close() + + +def test_cleanup_deletes_previous_after_grace(tmp_path, bootstrap: str, api: FakeApiTokensApi): + installed: list[str] = [] + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=installed.append, + token_dir=tmp_path, + refresh_interval=timedelta(days=1), + ) + try: + old_name = "Old Token" + api.seed(old_name, "api_old_token_xxxxxxxxxxxxxxxx") + slot = manager._slot # pylint: disable=protected-access + assert slot is not None + slot.previous = PreviousToken(name=old_name, minted_at=_utc(2020, 1, 1)) + slot.current.minted_at = _utc(2020, 1, 3) + manager._write_slot(slot) # pylint: disable=protected-access + manager._refresh_once() # pylint: disable=protected-access + assert old_name in api.deleted + finally: + manager.close() + + +def test_get_token_name_by_snippet_exists_but_unused(tmp_path, bootstrap: str, api: FakeApiTokensApi): + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=lambda _token: None, + token_dir=tmp_path, + refresh_interval=timedelta(days=30), + ) + try: + assert api.by_snippet_calls == 0 + name = manager._get_token_name_by_snippet(token_snippet(bootstrap)) # pylint: disable=protected-access + assert name == "Bootstrap Token" + assert api.by_snippet_calls == 1 + finally: + manager.close() + + +def test_falls_back_to_bootstrap_when_api_tokens_missing(tmp_path, bootstrap: str): + api = FakeApiTokensApi() + + def _missing(*_args, **_kwargs): + raise NotFoundException(status=404, reason="Not found") + + api.list_api_tokens = _missing # type: ignore[method-assign] + installed: list[str] = [] + manager = TokenManager( + bootstrap_token=bootstrap, + api_tokens_api=api, # type: ignore[arg-type] + set_api_token=installed.append, + token_dir=tmp_path, + refresh_interval=timedelta(days=30), + ) + try: + assert manager.working_token == bootstrap + assert installed[-1] == bootstrap + assert manager._refresh_enabled is False # pylint: disable=protected-access + assert manager._thread is None # pylint: disable=protected-access + finally: + manager.close()