diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..361916a --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +API_BASE_URL=https://api.onspring.com +SANDBOX_API_KEY=NEEDS_TO_BE_SET + +# Apps +TEST_APP_ID=NEEDS_TO_BE_SET +TEST_SURVEY_ID=NEEDS_TO_BE_SET +TEST_APP_ID_NO_ACCESS=NEEDS_TO_BE_SET +TEST_APP_IDS=NEEDS_TO_BE_SET +TEST_APP_IDS_NO_ACCESS=NEEDS_TO_BE_SET + +# Fields +TEST_FIELD_ID=NEEDS_TO_BE_SET +TEST_FIELD_ID_NO_ACCESS=NEEDS_TO_BE_SET +TEST_FIELD_IDS=NEEDS_TO_BE_SET +TEST_FIELD_IDS_NO_ACCESS=NEEDS_TO_BE_SET + +# Records +TEST_RECORD=NEEDS_TO_BE_SET +TEST_SURVEY_RECORD_ID=NEEDS_TO_BE_SET +TEST_TEXT_FIELD=NEEDS_TO_BE_SET + +# Files — Attachment +TEST_ATTACHMENT_FIELD=NEEDS_TO_BE_SET +TEST_ATTACHMENT_FIELD_NO_ACCESS_FIELD=NEEDS_TO_BE_SET +TEST_ATTACHMENT_FIELD_NO_ACCESS_APP=NEEDS_TO_BE_SET +TEST_ATTACHMENT=NEEDS_TO_BE_SET + +# Files — Image +TEST_IMAGE_FIELD=NEEDS_TO_BE_SET +TEST_IMAGE=NEEDS_TO_BE_SET + +# Lists +TEST_LIST_FIELD=NEEDS_TO_BE_SET +TEST_LIST_FIELD_NO_ACCESS=NEEDS_TO_BE_SET +TEST_LIST_ID=NEEDS_TO_BE_SET +TEST_LIST_ID_NO_ACCESS=NEEDS_TO_BE_SET +TEST_LIST_ITEM_ID_NO_ACCESS=NEEDS_TO_BE_SET + +# Reports +TEST_REPORT=NEEDS_TO_BE_SET +TEST_REPORT_NO_ACCESS=NEEDS_TO_BE_SET +TEST_REPORT_WITH_CHART_DATA=NEEDS_TO_BE_SET diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7f9f09d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,20 @@ +name: Publish +on: + push: + branches: [master] +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + - name: Setup UV + uses: astral-sh/setup-uv@v8.2.0 + with: + python-version: "3.13" + - name: Run uv build + run: uv build + - name: Publish to PYPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..092852d --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,40 @@ +name: Pull Request +on: + pull_request: + branches: [master] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + - name: Setup UV + uses: astral-sh/setup-uv@v8.2.0 + with: + python-version: "3.13" + - name: Run uv sync + run: uv sync + - name: Run linter + run: uv run ruff check src/ tests/ + - name: Run formatter + run: uv run ruff format --check src/ tests/ + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup UV + uses: astral-sh/setup-uv@v8.2.0 + with: + python-version: ${{ matrix.python-version }} + - name: Run uv sync + run: uv sync + - name: Load sandbox credentials + env: + SANDBOX_ENV: ${{ secrets.SANDBOX_ENV }} + run: printf '%s\n' "$SANDBOX_ENV" > .env + - name: Run tests + run: uv run pytest tests/ -q diff --git a/.gitignore b/.gitignore index baccf1a..91e4b25 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,9 @@ dmypy.json # Cython debug symbols cython_debug/ +# Ruff +.ruff_cache/ + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa5d282 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "autouse", + "pytestmark", + "respx" + ] +} \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt index 37e3a4d..935fb45 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Stevan Freeborn +Copyright (c) 2026 Stevan Freeborn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9fc81a5..221c3aa 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ This SDK was developed independently using their existing C# SDK, their swagger Requires use of Python 3.10.0 or later. -### Requests +### httpx -All methods for the `OnspringClient` make use of the [Requests](https://docs.python-requests.org/en/latest/) library to interact with the endpoints of version 2 of the Onspring API. +All methods for `OnspringClient` and `AsyncOnspringClient` make use of the [httpx](https://www.python-httpx.org/) library to interact with the endpoints of version 2 of the Onspring API. ## Installation @@ -39,8 +39,8 @@ In order to successfully interact with the Onspring Api you will need an API key The most common way to use the SDK is to create an `OnspringClient` instance and call its methods. Its constructor requires two parameters: -- `baseUrl` - currently this should always be: `https://api.onspring.com` -- `apiKey` - the value obtained by following the steps in the **API Key** section +- `url` - currently this should always be: `https://api.onspring.com` +- `key` - the value obtained by following the steps in the **API Key** section It is best practice to read these values in from a configuration file for both flexibility and security purposes. @@ -55,7 +55,7 @@ url = https://api.onspring.com Example constructing `OnspringClient`: ```python -from OnspringApiSdk.OnspringClient import OnspringClient +from onspring_api_sdk import OnspringClient from configparser import ConfigParser cfg = ConfigParser() @@ -67,22 +67,55 @@ url = cfg['prod']['url'] client = OnspringClient(url, key) ``` +### `AsyncOnspringClient` + +An async client is also available for use with `asyncio`: + +```python +from onspring_api_sdk import AsyncOnspringClient + +async with AsyncOnspringClient(url, key) as client: + response = await client.get_apps() +``` + +All methods mirror the sync client with `await` prefixed. + ### `ApiResponse` -Each `OnspringClient` method - aside from `CanConnect` - returns an `ApiResponse` object which will have the following properties: +Each client method returns an `ApiResponse` object with the following properties: -- `statusCode` - The http status code of the response. -- `data` - If the request was successful will contain the response data deserialized to custom python objects. +- `status_code` - The http status code of the response. +- `is_successful` - Whether the request was successful (status < 400). +- `data` - If the request was successful will contain the response data deserialized to Pydantic models. - `message` - A message that may provide more detail about the requests success or failure. -- `raw` - Exposes the raw response object of the request if you'd like to handle it directly. +- `raw_response` - Exposes the raw [`httpx.Response`](https://www.python-httpx.org/api/#response) object if you'd like to handle it directly. + +The goal with this `ApiResponse` object is to provide the flexibility to do with the response what you'd like while already having the JSON response deserialized to Python objects. + +### Error Handling + +You can check `is_successful` or call `raise_for_status()` to raise an exception on failure: + +```python +from onspring_api_sdk import OnspringError, OnspringAuthenticationError -The goal with this `ApiResponse` object is to provide the flexibility to do with the response what you'd like as well as already having the raw JSON response deserialized to python objects. +response = client.get_apps() -If you do want to handle and/or manipulate the response object yourself you will want to use the value of the `ApiResponse`'s `raw` property which will be a [`Response`](https://docs.python-requests.org/en/latest/user/advanced/#request-and-response-objects) object from the [Requests](https://docs.python-requests.org/en/latest/) library. +if not response.is_successful: + print(f'Request failed: {response.message}') + +# Or raise on failure: +try: + response.raise_for_status() +except OnspringAuthenticationError: + print('Check your API key') +except OnspringError as e: + print(f'Request failed: {e}') +``` ## Full API Documentation -You may wish to refer to the full [Onspring API documentation](https://software.onspring.com/hubfs/Training/Admin%20Guide%20-%20v2%20API.pdf) when determining which values to pass as parameters to some of the `OnspringClient` methods. There is also a [swagger page](https://api.onspring.com/swagger/index.html) that you can use for making exploratory requests. +You may wish to refer to the full [Onspring API documentation](https://software.onspring.com/hubfs/Training/Admin%20Guide%20-%20v2%20API.pdf) when determining which values to pass as parameters to some of the client methods. There is also a [swagger page](https://api.onspring.com/swagger/index.html) that you can use for making exploratory requests. ## Example Code @@ -93,9 +126,7 @@ The examples that follow assume you have created an `OnspringClient` as describe #### Verify connectivity ```python -canConnect = client.CanConnect() - -if canConnect: +if client.can_connect(): print('Connected successfully') else: print('Attempt to connect failed') @@ -108,13 +139,13 @@ else: Returns a paged collection of apps and/or surveys that can be paged through. By default the page size is 50 and page number is 1. ```python -response = client.GetApps() - -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') +response = client.get_apps() + +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for app in response.data.apps: print(f'Id: {app.id}') @@ -125,16 +156,16 @@ for app in response.data.apps: You can set your own page size and page number (max is 1,000) as well. ```python -from OnspringApiSdk.Models import PagingRequest +from onspring_api_sdk.models import PagingRequest + +paging_request = PagingRequest(page_number=1, page_size=100) +response = client.get_apps(paging_request=paging_request) -pagingRequest = PagingRequest(1, 100) -response = client.GetApps(pagingRequest) - -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for app in response.data.apps: print(f'Id: {app.id}') @@ -147,9 +178,9 @@ for app in response.data.apps: Returns an Onspring app or survey according to provided id. ```python -response = client.GetAppById(appId=195) +response = client.get_app_by_id(app_id=195) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'id: {response.data.app.id}') print(f'Name: {response.data.app.name}') print(f'href: {response.data.app.href}') @@ -160,9 +191,9 @@ print(f'href: {response.data.app.href}') Returns a collection of Onspring apps and/or surveys according to provided ids. ```python -response = client.GetAppsByIds(appIds=[195, 240]) +response = client.get_apps_by_ids(app_ids=[195, 240]) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Count: {response.data.count}') for app in response.data.apps: @@ -173,43 +204,36 @@ for app in response.data.apps: ### Fields -#### Helpers +#### Print Field Helper -Example `PrintField` method referenced in following examples. +An example helper for printing field details used in the following examples: ```python -def PrintField(field: Field): - +from onspring_api_sdk.models import OnspringField + + +def print_field(field: OnspringField): + print('Field:') print(f' Id: {field.id}') - print(f' App Id: {field.appId}') + print(f' App Id: {field.app_id}') print(f' Name: {field.name}') print(f' Type: {field.type}') print(f' Status: {field.status}') - print(f' IsRequired: {field.isRequired}') - print(f' IsUnique: {field.isUnique}') + print(f' Is Required: {field.is_required}') + print(f' Is Unique: {field.is_unique}') if field.type == 'Formula': - - print(f' Output Type: {field.outputType}') - - if field.outputType == 'ListValue': - - print(f' Multiplicity: {field.multiplicity}') - print(' Values:') - - for value in field.values: - - print(f' {value.AsString()}') + print(f' Output Type: {field.output_type}') if field.type == 'List': - print(f' Multiplicity: {field.multiplicity}') - print(' Values:') - for value in field.values: + if field.values: + print(' Values:') - print(f' {value.AsString()}') + for value in field.values: + print(f' {value}') ``` #### Get Field By Id @@ -217,10 +241,10 @@ def PrintField(field: Field): Returns an Onspring field according to provided id. ```python -response = client.GetFieldById(fieldId=9686) +response = client.get_field_by_id(field_id=9686) -print(f'Status Code: {response.statusCode}') -PrintField(response.data.field) +print(f'Status Code: {response.status_code}') +print_field(response.data.field) ``` #### Get Fields By Ids @@ -228,13 +252,13 @@ PrintField(response.data.field) Returns a collection of Onspring fields according to provided ids. ```python -response = client.GetFieldsByIds(fieldIds=[9686, 9687]) +response = client.get_fields_by_ids(field_ids=[9686, 9687]) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Count: {response.data.count}') for field in response.data.fields: - PrintField(field) + print_field(field) ``` #### Get Fields By App Id @@ -242,35 +266,34 @@ for field in response.data.fields: Returns a paged collection of fields that can be paged through. By default the page size is 50 and page number is 1. ```python -response = client.GetFieldsByAppId(appId=195) - - print(f'Status Code: {response.statusCode}') - print(f'Page Size: {response.data.pageSize}') - print(f'Page Number: {response.data.pageNumber}') - print(f'Total Pages: {response.data.totalPages}') - print(f'Total Records: {response.data.totalRecords}') +response = client.get_fields_by_app_id(app_id=195) + +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') - for field in response.data.fields: - PrintField(field) +for field in response.data.fields: + print_field(field) ``` You can set your own page size and page number (max is 1,000) as well. ```python -from OnspringApiSdk.Models import PagingRequest +from onspring_api_sdk.models import PagingRequest -pagingRequest = PagingRequest(1, 100) +paging_request = PagingRequest(page_number=1, page_size=100) +response = client.get_fields_by_app_id(app_id=195, paging_request=paging_request) -response = client.GetFieldsByAppId(appId=195, pagingRequest) - - print(f'Status Code: {response.statusCode}') - print(f'Page Size: {response.data.pageSize}') - print(f'Page Number: {response.data.pageNumber}') - print(f'Total Pages: {response.data.totalPages}') - print(f'Total Records: {response.data.totalRecords}') +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') - for field in response.data.fields: - PrintField(field) +for field in response.data.fields: + print_field(field) ``` ### Files @@ -280,16 +303,16 @@ response = client.GetFieldsByAppId(appId=195, pagingRequest) Returns the Onspring file's metadata. ```python -response = client.GetFileInfoById(recordId=1, fieldId=6990, fileId=274) +response = client.get_file_info_by_id(record_id=1, field_id=6990, file_id=274) -print(f'Status Code: {response.statusCode}') -print(f'Name: {response.data.fileInfo.name}') -print(f'Type: {response.data.fileInfo.type}') -print(f'Owner: {response.data.fileInfo.owner}') -print(f'Content Type: {response.data.fileInfo.contentType}') -print(f'Created Date: {response.data.fileInfo.createdDate}') -print(f'Modified Date: {response.data.fileInfo.modifiedDate}') -print(f'File Href: {response.data.fileInfo.fileHref}') +print(f'Status Code: {response.status_code}') +print(f'Name: {response.data.file_info.name}') +print(f'Type: {response.data.file_info.type}') +print(f'Owner: {response.data.file_info.owner}') +print(f'Content Type: {response.data.file_info.content_type}') +print(f'Created Date: {response.data.file_info.created_date}') +print(f'Modified Date: {response.data.file_info.modified_date}') +print(f'File Href: {response.data.file_info.file_href}') ``` #### Get File By Id @@ -297,54 +320,55 @@ print(f'File Href: {response.data.fileInfo.fileHref}') Returns the file itself. ```python -response = client.GetFileById(recordId=1, fieldId=6990, fileId=274) +response = client.get_file_by_id(record_id=1, field_id=6990, file_id=274) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Name: {response.data.file.name}') -print(f'Content Type: {response.data.file.contentType}') -print(f'Content Length: {response.data.file.contentLength}') +print(f'Content Type: {response.data.file.content_type}') +print(f'Content Length: {response.data.file.content_length}') -filePath = f'C:\\Users\\sfree\\Documents\\Temp\\{response.data.file.name}' +file_path = f'C:\\Users\\sfree\\Documents\\Temp\\{response.data.file.name}' -with open(filePath, "wb") as file: - - file.write(response.data.file.content) +with open(file_path, "wb") as f: + f.write(response.data.file.content) -print(f'File Location: {filePath}') +print(f'File Location: {file_path}') ``` #### Save File ```python -from OnspringApiSdk.Models import SaveFileRequest +from onspring_api_sdk.models import SaveFileRequest +from datetime import datetime import os import mimetypes -filePath = 'C:\\Users\\sfree\\Documents\\Temp\\Test Attachment.txt' -fileName = os.path.basename(filePath) -contentType = mimetypes.guess_type(filePath)[0] +file_path = 'C:\\Users\\sfree\\Documents\\Temp\\Test Attachment.txt' +file_name = os.path.basename(file_path) +content_type = mimetypes.guess_type(file_path)[0] request = SaveFileRequest( - recordId=60, - fieldId=6989, - fileName, - filePath, - contentType, + record_id=60, + field_id=6989, + file_name=file_name, + file_path=file_path, + content_type=content_type, notes='Initial revision', - modifiedDate=datetime.now()) + modified_date=datetime.now(), +) -response = client.SaveFile(request) +response = client.save_file(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'File Id: {response.data.id}') ``` #### Delete File By Id ```python -response = client.DeleteFileById(recordId=60, fieldId=6989, fileId=231) +response = client.delete_file_by_id(record_id=60, field_id=6989, file_id=231) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Message: {response.message}') ``` @@ -355,45 +379,50 @@ print(f'Message: {response.message}') To add a list value don't provide an id value. ```python -from OnspringApiSdk.Models import ListItemRequest +from onspring_api_sdk.models import ListItemRequest request = ListItemRequest( - listId=906, - name='Not Started', - id='', - numericValue=0, - color='#ffffff') + list_id=906, + name='Not Started', + numeric_value=0, + color='#ffffff', +) -response = client.AddOrUpdateListItem(request) +response = client.add_or_update_list_item(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Id: {response.data.id}') ``` To update a list value provide an id value. ```python -from OnspringApiSdk.Models import ListItemRequest +from onspring_api_sdk.models import ListItemRequest +import uuid request = ListItemRequest( - listId=906, - name='Pending', - id='4118d53a-9121-4345-8682-07f23d606daa', - numericValue=0, - color='#ffffff') + list_id=906, + name='Pending', + id=uuid.UUID('4118d53a-9121-4345-8682-07f23d606daa'), + numeric_value=0, + color='#ffffff', +) -response = client.AddOrUpdateListItem(request) +response = client.add_or_update_list_item(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Id: {response.data.id}') ``` #### Delete List Value ```python -response = client.DeleteListItem(listId=906, itemId='36f94d8c-2b9d-465e-9ad1-ede04109efc9') +response = client.delete_list_item( + list_id=906, + item_id='36f94d8c-2b9d-465e-9ad1-ede04109efc9', +) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Message: {response.message}') ``` @@ -401,112 +430,113 @@ print(f'Message: {response.message}') #### Get Records By App Id -Returns a paged colletion of records that can be paged through. By default the page size is 50 and page number is 1. +Returns a paged collection of records that can be paged through. By default the page size is 50 and page number is 1. ```python -request = GetRecordsByAppRequest(appId=195) +from onspring_api_sdk.models import GetRecordsByAppRequest -response = client.GetRecordsByAppId(request) +request = GetRecordsByAppRequest(app_id=195) +response = client.get_records_by_app_id(request) -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` You can set your own page size and page number (max is 1,000) as well. In addition to specifying what field values to return and in what format (Raw vs. Formatted) to return them. ```python -from OnspringApiSdk.Models import PagingRequest, GetRecordsByAppRequest -from OnspringApiSdk.Enums import DataFormat - -pagingRequest = PagingRequest(1,10) +from onspring_api_sdk.models import GetRecordsByAppRequest +from onspring_api_sdk.enums import DataFormat request = GetRecordsByAppRequest( - appId=195, - fieldIds=[9686], - dataFormat=DataFormat.Formatted.name, - pagingRequest) + app_id=195, + field_ids=[9686], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=10, +) -response = client.GetRecordsByAppId(request) +response = client.get_records_by_app_id(request) -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` #### Get Record By Id -Returns an onspring record based on the provided app and record ids. +Returns an Onspring record based on the provided app and record ids. ```python -from OnspringApiSdk.Models import GetRecordByIdRequest +from onspring_api_sdk.models import GetRecordByIdRequest -request = GetRecordByIdRequest(appId=195, recordId=60) +request = GetRecordByIdRequest(app_id=195, record_id=60) +response = client.get_record_by_id(request) -response = client.GetRecordById(request) - -print(f'Status Code: {response.statusCode}') -print(f'AppId: {response.data.appId}') -print(f'RecordId: {response.data.recordId}') +print(f'Status Code: {response.status_code}') +print(f'AppId: {response.data.app_id}') +print(f'RecordId: {response.data.record_id}') for field in response.data.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` You can also specify what field values to return and in what format (Raw vs. Formatted) to return them. ```python -from OnspringApiSdk.Models import GetRecordByIdRequest -from OnspringApiSdk.Enums import DataFormat +from onspring_api_sdk.models import GetRecordByIdRequest +from onspring_api_sdk.enums import DataFormat request = GetRecordByIdRequest( - appId=195, - recordId=60, - fieldIds=[9686], - dataFormat=DataFormat.Formatted.name) + app_id=195, + record_id=60, + field_ids=[9686], + data_format=DataFormat.Formatted.name, +) -response = client.GetRecordById(request) +response = client.get_record_by_id(request) -print(f'Status Code: {response.statusCode}') -print(f'AppId: {response.data.appId}') -print(f'RecordId: {response.data.recordId}') +print(f'Status Code: {response.status_code}') +print(f'AppId: {response.data.app_id}') +print(f'RecordId: {response.data.record_id}') for field in response.data.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` #### Delete Record By Id ```python -response = client.DeleteRecordById(appId=195, recordId=60) +response = client.delete_record_by_id(app_id=195, record_id=60) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Message: {response.message}') ``` @@ -515,117 +545,117 @@ print(f'Message: {response.message}') Returns a collection of Onspring records based on the provided appId and recordIds. ```python -from OnspringApiSdk.Models import GetBatchRecordsRequest - -request = GetBatchRecordsRequest(appId=195, recordIds=[1, 2, 3]) +from onspring_api_sdk.models import GetBatchRecordsRequest -response = client.GetRecordsByIds(request) +request = GetBatchRecordsRequest(app_id=195, record_ids=[1, 2, 3]) +response = client.get_records_by_ids(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Count: {response.data.count}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` You can also specify what field values to return and in what format (Raw vs. Formatted) to return them. ```python -from OnspringApiSdk.Models import GetBatchRecordsRequest -from OnspringApiSdk.Enums import DataFormat +from onspring_api_sdk.models import GetBatchRecordsRequest +from onspring_api_sdk.enums import DataFormat request = GetBatchRecordsRequest( - appId=195, - recordIds=[1, 2, 3], - fieldIds=[9686], - dataFormat=DataFormat.Formatted.name) + app_id=195, + record_ids=[1, 2, 3], + field_ids=[9686], + data_format=DataFormat.Formatted.name, +) -response = client.GetRecordsByIds(request) +response = client.get_records_by_ids(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Count: {response.data.count}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` #### Query Records -Returns a paged colletion of records based on a criteria that can be paged through. By default the page size is 50 and page number is 1. +Returns a paged collection of records based on a criteria that can be paged through. By default the page size is 50 and page number is 1. ```python -from OnspringApiSdk.Models import QueryRecordsRequest +from onspring_api_sdk.models import QueryRecordsRequest -fieldId = 6983 +field_id = 6983 operator = 'eq' -value = '\'Test Task 5\'' - -request = QueryRecordsRequest(appId=195, filter=f'{fieldId} {operator} {value}') +value = "'Test Task 5'" -response = client.QueryRecords(request) +request = QueryRecordsRequest(app_id=195, filter=f'{field_id} {operator} {value}') +response = client.query_records(request) -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` You can set your own page size and page number (max is 1,000) as well. In addition to specifying what field values to return and in what format (Raw vs. Formatted) to return them. ```python -from OnspringApiSdk.Models import PagingRequest, QueryRecordsRequest -from OnspringApiSdk.Enums import DataFormat +from onspring_api_sdk.models import QueryRecordsRequest +from onspring_api_sdk.enums import DataFormat -pagingRequest = PagingRequest(1, 10) -fieldId = 6983 +field_id = 6983 operator = 'eq' -value = '\'Test Task 5\'' +value = "'Test Task 5'" request = QueryRecordsRequest( - appId=195, - filter=f'{fieldId} {operator} {value}', - fieldIds=[9686], - dataFormat=DataFormat.Formatted.name, - pagingRequest) - -response = client.QueryRecords(request) - -print(f'Status Code: {response.statusCode}') -print(f'Page Size: {response.data.pageSize}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Total Pages: {response.data.totalPages}') -print(f'Total Records: {response.data.totalRecords}') + app_id=195, + filter=f'{field_id} {operator} {value}', + field_ids=[9686], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=10, +) + +response = client.query_records(request) + +print(f'Status Code: {response.status_code}') +print(f'Page Size: {response.data.page_size}') +print(f'Page Number: {response.data.page_number}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') for record in response.data.records: - print(f'AppId: {record.appId}') - print(f'RecordId: {record.recordId}') + print(f'AppId: {record.app_id}') + print(f'RecordId: {record.record_id}') for field in record.fields: print(f'Type: {field.type}') - print(f'FieldId: {field.fieldId}') - print(f'Value: {field.GetResultValueString()}') + print(f'FieldId: {field.field_id}') + print(f'Value: {field.value}') ``` For further details on constructing the `filter` parameter please refer to the [documentation](https://software.onspring.com/hubfs/Training/Admin%20Guide%20-%20v2%20API.pdf) for v2 of the Onspring API. @@ -635,27 +665,34 @@ For further details on constructing the `filter` parameter please refer to the [ You can add a record by not providing a record id value. If successful will return the id of the added record. ```python -from OnspringApiSdk.Models import StringFieldValue, GuidFieldValue, DateFieldValue, IntegerListValue, Record +from onspring_api_sdk.models import ( + StringFieldValue, + GuidFieldValue, + DateFieldValue, + IntegerListValue, + Record, +) +import uuid +from datetime import datetime fields = [] status = uuid.UUID('4118d53a-9121-4345-8682-07f23d606daa') -dueDate = datetime.utcnow() +due_date = datetime.utcnow() -fields.append(StringFieldValue(6983, 'Test Task via API')) -fields.append(StringFieldValue(6984, 'This is a task.')) -fields.append(GuidFieldValue(6986, status)) -fields.append(DateFieldValue(6985, dueDate)) -fields.append(IntegerListValue(6987, [4])) +fields.append(StringFieldValue(field_id=6983, value='Test Task via API')) +fields.append(StringFieldValue(field_id=6984, value='This is a task.')) +fields.append(GuidFieldValue(field_id=6986, value=status)) +fields.append(DateFieldValue(field_id=6985, value=due_date)) +fields.append(IntegerListValue(field_id=6987, value=[4])) -record = Record( - appId=195, - fields) +record = Record(app_id=195, fields=fields) -response = client.AddOrUpdateRecord(record) +response = client.add_or_update_record(record) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Id: {response.data.id}') + for warning in response.data.warnings: print(f'Warning: {warning}') ``` @@ -663,28 +700,34 @@ for warning in response.data.warnings: You can update a record by providing its id. If successful will return the id of record updated. ```python -from OnspringApiSdk.Models import StringFieldValue, GuidFieldValue, DateFieldValue, IntegerListValue, Record +from onspring_api_sdk.models import ( + StringFieldValue, + GuidFieldValue, + DateFieldValue, + IntegerListValue, + Record, +) +import uuid +from datetime import datetime fields = [] status = uuid.UUID('1c1c5f7e-cd03-4b70-9790-0f83b24b5863') -dueDate = datetime.utcnow() +due_date = datetime.utcnow() -fields.append(StringFieldValue(6983, 'Test Task via API')) -fields.append(StringFieldValue(6984, 'This is a task.')) -fields.append(GuidFieldValue(6986, status)) -fields.append(DateFieldValue(6985, dueDate)) -fields.append(IntegerListValue(6987, [4])) +fields.append(StringFieldValue(field_id=6983, value='Test Task via API')) +fields.append(StringFieldValue(field_id=6984, value='This is a task.')) +fields.append(GuidFieldValue(field_id=6986, value=status)) +fields.append(DateFieldValue(field_id=6985, value=due_date)) +fields.append(IntegerListValue(field_id=6987, value=[4])) -record = Record( - appId=195, - fields, - recordId=103) +record = Record(app_id=195, fields=fields, record_id=103) -response = client.AddOrUpdateRecord(record) +response = client.add_or_update_record(record) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Id: {response.data.id}') + for warning in response.data.warnings: print(f'Warning: {warning}') ``` @@ -692,13 +735,13 @@ for warning in response.data.warnings: #### Delete Records By Ids ```python -from OnspringApiSdk.Models import DeleteBatchRecordsRequest +from onspring_api_sdk.models import DeleteBatchRecordsRequest -request = DeleteBatchRecordsRequest(appId=195, recordIds=[1, 2, 3]) +request = DeleteBatchRecordsRequest(app_id=195, record_ids=[1, 2, 3]) -response = client.DeleteRecordsByIds(request) +response = client.delete_records_by_ids(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print(f'Message: {response.message}') ``` @@ -709,39 +752,43 @@ print(f'Message: {response.message}') Returns the report for the provided id. ```python -from OnspringApiSdk.Models import GetReportByIdRequest - -request = GetReportByIdRequest(reportId=53) +from onspring_api_sdk.models import GetReportByIdRequest -response = client.GetReportById(request) +request = GetReportByIdRequest(report_id=53) +response = client.get_report_by_id(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print('Columns:') print(f'{", ".join(response.data.columns)}') print('Rows:') + for row in response.data.rows: - print(f'Record Id {row.recordId}: {", ".join([str(cell) for cell in row.cells])}') + cells = ', '.join([str(cell) for cell in row.cells]) + print(f'Record Id {row.record_id}: {cells}') ``` You can also specify the format of the data in the report as well as whether you are requesting the report's data or its chart data. ```python -from OnspringApiSdk.Models import GetReportByIdRequest -from OnspringApiSdk.Enums import DataFormat, ReportDataType +from onspring_api_sdk.models import GetReportByIdRequest +from onspring_api_sdk.enums import DataFormat, ReportDataType request = GetReportByIdRequest( - reportId=53, - apiDataFormat=DataFormat.Formatted.name, - dataFormat=ReportDataType.ChartData.name) + report_id=53, + api_data_format=DataFormat.Formatted.name, + data_type=ReportDataType.ChartData.name, +) -response = client.GetReportById(request) +response = client.get_report_by_id(request) -print(f'Status Code: {response.statusCode}') +print(f'Status Code: {response.status_code}') print('Columns:') print(f'{", ".join(response.data.columns)}') print('Rows:') + for row in response.data.rows: - print(f'Record Id {row.recordId}: {", ".join([str(cell) for cell in row.cells])}') + cells = ', '.join([str(cell) for cell in row.cells]) + print(f'Record Id {row.record_id}: {cells}') ``` #### Get Reports By App Id @@ -749,10 +796,9 @@ for row in response.data.rows: Returns a paged collection of reports that can be paged through. By default the page size is 50 and page number is 1. ```python -response = client.GetReportsByAppId(appId=195) +response = client.get_reports_by_app_id(app_id=195) -print(f'Status Code: {response.statusCode}') -print(f'App Id: {appId}') +print(f'Status Code: {response.status_code}') print('Reports:') for report in response.data.reports: @@ -764,18 +810,16 @@ for report in response.data.reports: You can set your own page size and page number (max is 1,000) as well. ```python -from OnspringApiSdk.Models import PagingRequest - -pagingRequest = PagingRequest(1,10) +from onspring_api_sdk.models import PagingRequest -response = client.GetReportsByAppId(appId=195, pagingRequest) +paging_request = PagingRequest(page_number=1, page_size=10) +response = client.get_reports_by_app_id(app_id=195, paging_request=paging_request) -print(f'Status Code: {response.statusCode}') -print(f'Page Number: {response.data.pageNumber}') -print(f'Page Number: {response.data.pageSize}') -print(f'Page Number: {response.data.totalPages}') -print(f'Page Number: {response.data.totalRecords}') -print(f'App Id: {appId}') +print(f'Status Code: {response.status_code}') +print(f'Page Number: {response.data.page_number}') +print(f'Page Size: {response.data.page_size}') +print(f'Total Pages: {response.data.total_pages}') +print(f'Total Records: {response.data.total_records}') print('Reports:') for report in response.data.reports: diff --git a/pyproject.toml b/pyproject.toml index fa7093a..cb53cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,58 @@ [build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "OnspringApiSdk" +version = "3.0.0" +description = "A package for interacting with version 2 of the Onspring API." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "StevanFreeborn", email = "stevan.freeborn@gmail.com" }, +] +license = { text = "MIT" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "pydantic>=2.0.0", + "httpx>=0.24.0", +] + +[project.urls] +Homepage = "https://github.com/StevanFreeborn/onspring-api-sdk-python" +"Bug Tracker" = "https://github.com/StevanFreeborn/onspring-api-sdk-python/issues" + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=7.1.0", + "pytest-rerunfailures>=14.0", + "python-dotenv>=1.0.0", + "respx>=0.20.0", + "ruff>=0.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/onspring_api_sdk"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +markers = [ + "integration: marks tests as integration tests (real API calls)", +] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["D", "E", "F", "I", "N"] +ignore = ["D203", "D213"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7ae3148..0000000 --- a/setup.cfg +++ /dev/null @@ -1,25 +0,0 @@ -[metadata] -name = OnspringApiSdk -version = 2.0.2 -author = StevanFreeborn -author_email = stevan.freeborn@gmail.com -description = A package for interacting with version 2 of the Onspring API. -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/StevanFreeborn/onspring-api-sdk-python -project_urls = - Bug Tracker = https://github.com/StevanFreeborn/onspring-api-sdk-python/issues -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: MIT License - Operating System :: OS Independent - -[options] -package_dir = - = src -packages = find: -python_requires = >=3.10 -install_requires = Requests - -[options.packages.find] -where = src \ No newline at end of file diff --git a/src/OnspringApiSDK.Tests/Tests_OnspringClient.py b/src/OnspringApiSDK.Tests/Tests_OnspringClient.py deleted file mode 100644 index a439df1..0000000 --- a/src/OnspringApiSDK.Tests/Tests_OnspringClient.py +++ /dev/null @@ -1,219 +0,0 @@ -import sys - -import requests_mock - -sys.path.append('src') - -from OnspringApiSdk.OnspringClient import OnspringClient - - -@requests_mock.Mocker(kw='mock') -class TestOnspringClient(object): - test_url = 'https://test.com' - test_apiKey = 'apiKey' - client = OnspringClient(test_url, test_apiKey) - - def test_GetFieldsByAppId_WhenFieldContainsListValues_ItShouldReturnThoseValues(self, **kwargs): - mockResponse = { - "pageNumber": 1, - "pageSize": 2, - "totalPages": 1, - "totalRecords": 2, - "items": [ - { - "multiplicity": "SingleSelect", - "listId": 638, - "values": [ - { - "id": "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84", - "name": "list_value_1", - "sortOrder": 1, - "numericValue": 1, - "color": "#008e8e" - }, - { - "id": "0421e502-7f76-480a-9311-363aca3560bc", - "name": "list_value_2", - "sortOrder": 2, - "numericValue": 2, - "color": "#a186be" - }, - { - "id": "285b91c1-5800-47cb-a030-8cf7cdd7cdf1", - "name": "updated_list_value_1676840661138}", - "sortOrder": 3, - "numericValue": 1, - "color": "#000000" - } - ], - "id": 4801, - "appId": 130, - "name": "single_select_list_field", - "type": "List", - "status": "Enabled", - "isRequired": True, - "isUnique": False - }, - { - "outputType": "ListValue", - "values": [ - { - "id": "b235afb2-b786-4c87-bce9-fbd700e246c1", - "name": "list_value_1", - "sortOrder": 1, - "numericValue": 1, - "color": "#6dcff6" - }, - { - "id": "5cd7cd55-d6a6-40e0-a560-8aa407c13210", - "name": "list_value_2", - "sortOrder": 2, - "numericValue": 2, - "color": "#8e468e" - } - ], - "id": 4815, - "appId": 130, - "name": "list_formula_field", - "type": "Formula", - "status": "Enabled", - "isRequired": False, - "isUnique": False - }, - ] - } - - kwargs['mock'].get(self.test_url + '/Fields/appId/1', json=mockResponse) - - response = self.client.GetFieldsByAppId(1) - - field_4801 = response.data.fields[0] - field_4815 = response.data.fields[1] - - assert field_4801.id == 4801, "Field id should be 4801" - assert field_4801.listId == 638, "Field list id should be 638" - assert field_4801.multiplicity == "SingleSelect", "Field multiplicity should be SingleSelect" - assert field_4801.values is not None, "Field list values should not be None" - - field_4801_value = field_4801.values[0] - - assert field_4801_value.id == "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84", "Field value id should be 2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84" - assert field_4801_value.name == "list_value_1", "Field value name should be list_value_1" - assert field_4801_value.sortOrder == 1, "Field value sort order should be 1" - assert field_4801_value.numericValue == 1, "Field value numeric value should be 1" - assert field_4801_value.color == "#008e8e", "Field value color should be #008e8e" - - assert field_4815.id == 4815, "Field id should be 4815" - assert field_4815.listId is None, "Field list id should be None" - assert field_4815.multiplicity is None, "Field multiplicity should be None" - assert field_4815.values is not None, "Field list values should not be None" - assert field_4815.outputType == 'ListValue', 'Field output type should be ListValue' - - field_4815_value = field_4815.values[0] - - assert field_4815_value.id == "b235afb2-b786-4c87-bce9-fbd700e246c1", "Field value id should be b235afb2-b786-4c87-bce9-fbd700e246c1" - assert field_4815_value.name == "list_value_1", "Field value name should be list_value_1" - assert field_4815_value.sortOrder == 1, "Field value sort order should be 1" - assert field_4815_value.numericValue == 1, "Field value numeric value should be 1" - assert field_4815_value.color == "#6dcff6", "Field value color should be #6dcff6" - - def test_GetFieldsByIds_WhenFieldContainsListValues_ItShouldReturnThoseValues(self, **kwargs): - mockResponse = { - "count": 2, - "items": [ - { - "multiplicity": "SingleSelect", - "listId": 638, - "values": [ - { - "id": "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84", - "name": "list_value_1", - "sortOrder": 1, - "numericValue": 1, - "color": "#008e8e" - }, - { - "id": "0421e502-7f76-480a-9311-363aca3560bc", - "name": "list_value_2", - "sortOrder": 2, - "numericValue": 2, - "color": "#a186be" - }, - { - "id": "285b91c1-5800-47cb-a030-8cf7cdd7cdf1", - "name": "updated_list_value_1676840661138}", - "sortOrder": 3, - "numericValue": 1, - "color": "#000000" - } - ], - "id": 4801, - "appId": 130, - "name": "single_select_list_field", - "type": "List", - "status": "Enabled", - "isRequired": True, - "isUnique": False - }, - { - "outputType": "ListValue", - "values": [ - { - "id": "b235afb2-b786-4c87-bce9-fbd700e246c1", - "name": "list_value_1", - "sortOrder": 1, - "numericValue": 1, - "color": "#6dcff6" - }, - { - "id": "5cd7cd55-d6a6-40e0-a560-8aa407c13210", - "name": "list_value_2", - "sortOrder": 2, - "numericValue": 2, - "color": "#8e468e" - } - ], - "id": 4815, - "appId": 130, - "name": "list_formula_field", - "type": "Formula", - "status": "Enabled", - "isRequired": False, - "isUnique": False - } - ] - } - - kwargs['mock'].post(self.test_url + '/Fields/batch-get', json=mockResponse) - - response = self.client.GetFieldsByIds([4801, 4815]) - - field_4801 = response.data.fields[0] - field_4815 = response.data.fields[1] - - assert field_4801.id == 4801, "Field id should be 4801" - assert field_4801.listId == 638, "Field list id should be 638" - assert field_4801.multiplicity == "SingleSelect", "Field multiplicity should be SingleSelect" - assert field_4801.values is not None, "Field list values should not be None" - - field_4801_value = field_4801.values[0] - - assert field_4801_value.id == "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84", "Field value id should be 2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84" - assert field_4801_value.name == "list_value_1", "Field value name should be list_value_1" - assert field_4801_value.sortOrder == 1, "Field value sort order should be 1" - assert field_4801_value.numericValue == 1, "Field value numeric value should be 1" - assert field_4801_value.color == "#008e8e", "Field value color should be #008e8e" - - assert field_4815.id == 4815, "Field id should be 4815" - assert field_4815.listId is None, "Field list id should be None" - assert field_4815.multiplicity is None, "Field multiplicity should be None" - assert field_4815.values is not None, "Field list values should not be None" - assert field_4815.outputType == 'ListValue', 'Field output type should be ListValue' - - field_4815_value = field_4815.values[0] - - assert field_4815_value.id == "b235afb2-b786-4c87-bce9-fbd700e246c1", "Field value id should be b235afb2-b786-4c87-bce9-fbd700e246c1" - assert field_4815_value.name == "list_value_1", "Field value name should be list_value_1" - assert field_4815_value.sortOrder == 1, "Field value sort order should be 1" - assert field_4815_value.numericValue == 1, "Field value numeric value should be 1" - assert field_4815_value.color == "#6dcff6", "Field value color should be #6dcff6" \ No newline at end of file diff --git a/src/OnspringApiSdk/Endpoints.py b/src/OnspringApiSdk/Endpoints.py deleted file mode 100644 index 3ad0f79..0000000 --- a/src/OnspringApiSdk/Endpoints.py +++ /dev/null @@ -1,323 +0,0 @@ -import uuid - -# connectivity endpoints - -def GetPingEndpoint(baseUrl: str) -> str: - """ - Returns the ping endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The ping endpoint as a string. - """ - - return f'{baseUrl}/Ping' - -# app endpoints - -def GetAppsEndpoint(baseUrl: str) -> str: - """ - Returns the get apps endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The get apps endpoint as a string. - """ - - return f'{baseUrl}/Apps' - -def GetAppByIdEndpoint(baseUrl: str, appId: int) -> str: - """ - Returns the get app by id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app being requested. - - Returns: - The get app by id endpoint as a string. - """ - - return f'{baseUrl}/Apps/id/{appId}' - -def GetAppsByIdsEndpoint(baseUrl: str) -> str: - """ - Returns the get apps by ids endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The get apps by ids endpoint as a string. - """ - - return f'{baseUrl}/Apps/batch-get' - -# field endpoints - -def GetFieldByIdEndpoint(baseUrl: str, fieldId: int) -> str: - """ - Returns the get field by id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - fieldId (`int`): The id of the field being requested. - - Returns: - The get field by id endpoint as a string. - """ - - return f'{baseUrl}/Fields/id/{fieldId}' - -def GetFieldsByIdsEndpoint(baseUrl: str) -> str: - """ - Returns the get fields by ids endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The get fields by ids endpoint as a string. - """ - - return f'{baseUrl}/Fields/batch-get' - -def GetFieldsByAppIdEndpoint(baseUrl: str, appId: int) -> str: - """ - Returns the get fields by app id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app whose fields are being requested. - - Returns: - The get fields by app id endpoint as a string. - """ - - return f'{baseUrl}/Fields/appId/{appId}' - -# file endpoints - -def GetFileInfoByIdEndpoint(baseUrl: str, recordId: int, fieldId: int, fileId: int) -> str: - """ - Returns the get file info by its id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - recordId (`int`): The id for the record where the file resides. - fieldId (`int`): The id for the field in the record where the file is held. - fileId ('int'): The id of the file. - - Returns: - The get file info by its id endpoint as a string. - """ - - return f'{baseUrl}/Files/recordId/{recordId}/fieldId/{fieldId}/fileId/{fileId}' - -def DeleteFileByIdEndpoint(baseUrl: str, recordId: int, fieldId: int, fileId: int) -> str: - """ - Returns the delete file by its id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - recordId (`int`): The id for the record where the file resides. - fieldId (`int`): The id for the field in the record where the file is held. - fileId ('int'): The id of the file. - - Returns: - The delete file by its id endpoint as a string. - """ - - return f'{baseUrl}/Files/recordId/{recordId}/fieldId/{fieldId}/fileId/{fileId}' - -def GetFileByIdEndpoint(baseUrl: str, recordId: int, fieldId: int, fileId: int) -> str: - """ - Returns the get file by its id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - recordId (`int`): The id for the record where the file resides. - fieldId (`int`): The id for the field in the record where the file is held. - fileId ('int'): The id of the file. - - Returns: - The get file by its id endpoint as a string. - """ - - return f'{baseUrl}/Files/recordId/{recordId}/fieldId/{fieldId}/fileId/{fileId}/file' - -def SaveFileEndpoint(baseUrl: str) -> str: - """ - Returns the save file endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The save file endpoint as a string. - """ - - return f'{baseUrl}/Files' - -# list endpoints - -def AddOrUpdateListItemEndpoint(baseUrl, listId: int) -> str: - """ - Returns the add or update list item endpoint. - - Args: - baseUrl (`str`): The base url for the api. - listId (`int`): The id of the list for which the item belongs when being updated or being added to. - - Returns: - The add or update list item endpoint as a string. - """ - - return f'{baseUrl}/Lists/id/{listId}/items' - -def DeleteListItemEndpoint(baseUrl: str, listId: int, itemId: uuid) -> str: - """ - Returns the delete list item endpoint. - - Args: - baseUrl (`str`): The base url for the api. - listId (`int`): The id of the list for which the list item belongs. - itemId (`int`): The id list item. - - Returns: - The delete list item endpoint as a string. - """ - - return f'{baseUrl}/Lists/id/{listId}/itemId/{itemId}' - -# record endpoints - -def GetRecordsByAppIdEndpoint(baseUrl, appId: int) -> str: - """ - Returns the get records by app id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app where the records reside. - - Returns: - The get records by app id endpoint as a string. - """ - - return f'{baseUrl}/Records/appId/{appId}' - -def GetRecordByIdEndpoint(baseUrl, appId: int, recordId: int) -> str: - """ - Returns the get record by id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app where the record resides. - recordId (`int`): The id of the record. - - Returns: - The get record by id endpoint as a string. - """ - - return f'{baseUrl}/Records/appId/{appId}/recordId/{recordId}' - -def DeleteRecordByIdEndpoint(baseUrl, appId: int, recordId: int) -> str: - """ - Returns the delete record by id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app where the record resides. - recordId (`int`): The id of the record. - - Returns: - The delete record by id endpoint as a string. - """ - - return f'{baseUrl}/Records/appId/{appId}/recordId/{recordId}' - -def GetRecordsByIdsEndpoint(baseUrl) -> str: - """ - Returns the get records by ids endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The get records by ids endpoint as a string. - """ - - return f'{baseUrl}/Records/batch-get' - -def QueryRecordsEndpoint(baseUrl) -> str: - """ - Returns the query records endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The query records endpoint as a string. - """ - - return f'{baseUrl}/Records/Query' - -def AddOrUpdateRecordEndpoint(baseUrl) -> str: - """ - Returns the add or update record endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The add or update record endpoint as a string. - """ - - return f'{baseUrl}/Records' - -def DeleteRecordsByIds(baseUrl) -> str: - """ - Returns the delete records by ids endpoint. - - Args: - baseUrl (`str`): The base url for the api. - - Returns: - The delete records by ids endpoint as a string. - """ - - return f'{baseUrl}/Records/batch-delete' - -# report endpoints - -def GetReportByIdEndpoint(baseUrl, reportId: int) -> str: - """ - Returns the get report by id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - reportId (`int`): The id of the report. - - Returns: - The get report by id endpoint as a string. - """ - - return f'{baseUrl}/Reports/id/{reportId}' - -def GetReportsByAppIdEndpoint(baseUrl, appId: int) -> str: - """ - Returns the get reports by app id endpoint. - - Args: - baseUrl (`str`): The base url for the api. - appId (`int`): The id of the app where the reports reside. - - Returns: - The get reports by app id endpoint as a string. - """ - - return f'{baseUrl}/Reports/appId/{appId}' \ No newline at end of file diff --git a/src/OnspringApiSdk/Enums.py b/src/OnspringApiSdk/Enums.py deleted file mode 100644 index ebd87f6..0000000 --- a/src/OnspringApiSdk/Enums.py +++ /dev/null @@ -1,54 +0,0 @@ -from enum import Enum - -class DataFormat(Enum): - """ - The possible data format types for record field values. - """ - - Raw:int = 0 - Formatted:int = 1 - -class ReportDataType(Enum): - """ - The possible report data types for reports. - """ - ReportData:int = 0 - ChartData:int = 1 - -class ResultValueType(Enum): - """ - The possible types for record field values. - """ - String:int = 0 - Integer:int = 1 - Decimal:int = 2 - Date:int = 3 - TimeSpan:int = 4 - Guid:int = 5 - StringList:int = 6 - IntegerList:int = 7 - GuidList:int = 8 - AttachmentList:int = 9 - ScoringGroupList:int = 10 - FileList:int = 11 - -class Increment(Enum): - """ - The possible values for the increment property of timespan data in an Onspring timespan field. - """ - Seconds:str = "Second(s)" - Minutes:str = "Minute(s)" - Hours:str = "Hour(s)" - Days:str = "Day(s)" - Weeks:str = "Week(s)" - Months:str = "Month(s)" - Years:str = "Year(s)" - -class Recurrence(Enum): - """ - The possible values for the recurrence property of timespan data in an Onspring timespan field. - """ - Empty:str = "None" - EndByDate:str = "EndByDate" - EndAfterOccurrences:str = 'EndAfterOccurrences' - diff --git a/src/OnspringApiSdk/Helpers.py b/src/OnspringApiSdk/Helpers.py deleted file mode 100644 index 59d556a..0000000 --- a/src/OnspringApiSdk/Helpers.py +++ /dev/null @@ -1,13 +0,0 @@ -from datetime import datetime -from OnspringApiSdk.Enums import * - -def parseDate(date: str) -> datetime: - - if date==None: - return None - - for format in ["%Y-%m-%dT%H:%M:%S.%fZ","%Y-%m-%dT%H:%M:%SZ"]: - try: - return datetime.strptime(date, format) - except ValueError: - pass \ No newline at end of file diff --git a/src/OnspringApiSdk/Models.py b/src/OnspringApiSdk/Models.py deleted file mode 100644 index c07ea25..0000000 --- a/src/OnspringApiSdk/Models.py +++ /dev/null @@ -1,1221 +0,0 @@ -import datetime -import uuid - -from OnspringApiSdk.Enums import * -from decimal import Decimal -from datetime import datetime -from OnspringApiSdk.Helpers import parseDate -from requests import Response - -# paging - -class PagingRequest: - """ - An object to represent the page number and page size of a paginated request made by an `OnspringClient`. - - Attributes: - pageNumber (`int`): The page number that will be requested. - pageSize (`int`): The size of the page that will be requested. - """ - - def __init__(self, pageNumber: int, pageSize: int): - self.pageNumber:int = pageNumber - self.pageSize:int = pageSize - -#app specific - -class App: - """ - An object to represent an Onspring app. - - Attributes: - href (`str`): The href for the Onspring app. - id (`int`): The id of the Onspring app. - name (`str`): The name of the Onspring app. - """ - - def __init__(self, href: str, id: int, name: str): - self.href:str = href - self.id:int = id - self.name:str = name - -class GetAppsResponse: - """ - An object to represent a paginated response to a request made by an `OnspringClient` to request a collection of Onspring apps. - - Attributes: - pageNumber (`int`): The page number returned. - pageSize (`int`): The size of the page returned. - totalPages (`int`): The total number of pages for the request. - totalRecords (`int`): The total records for the request. - apps (`list[Models.App]`): The apps requested. - """ - - def __init__(self, pageNumber: int, pageSize: int, totalPages:int , totalRecords: int, apps: list[App]): - self.pageNumber:int = pageNumber - self.pageSize:int = pageSize - self.totalPages:int = totalPages - self.totalRecords:int = totalRecords - self.apps:list[App] = apps - -class GetAppByIdResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request an Onspring app. - - Attributes: - app (`Models.App`): The requested app. - """ - - def __init__(self, app: App): - self.app:App = app - -class GetAppsByIdsResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request a batch of Onspring apps. - - Attributes: - count (`int`): The number of apps requested. - apps (`list[Models.App]`): The apps requested. - """ - - def __init__(self, count: int, apps: list[App]): - self.count:int = count - self.apps:list[App] = apps - -# field specific - -class ListValue: - """ - An object to represent an Onspring list value. - - Attributes: - id (`int`): The id of the Onspring list value. - name (`str`): The list value's name. - sortOrder (`int`): The list value's sort order in respect to other values in the same list. - numericalValue (`Decimal`): The numeric value assigned to the list value. - color (`str`): The color assigned to the list value. - """ - - def __init__(self, id: int, name: str, sortOrder: int, numericValue: Decimal, color: str): - self.id:int = id - self.name:str = name - self.sortOrder:int = sortOrder - - if numericValue != None: - self.numericValue:Decimal = Decimal(numericValue) - else: - self.numericValue:Decimal = numericValue - - self.color:str = color - - def AsString(self) -> str: - """ - Gets the list value as a comma separated string of it's properties and their values. - - Args: - None - - Returns: - A `str` representation of the list value object. - """ - - return f'Id: {self.id}, Name: {self.name}, Value: {self.numericValue}, Sort Order: {self.sortOrder}, Color: {self.color}' - -class Field: - """ - An object to represent an Onspring field. - - Attributes: - id (`int`): The id of the Onspring field. - appId ('int'): The id of the Onspring app where the field resides. - name (`str`): The name of the field. - type (`str`): The type of the field. - status (`str`): The stuat of the field. - isRequired (`bool`): Indicates whether the field is required in Onspring or not. - isUnique (`bool`): Indicates whether the field requires unique values in Onspring or not. - listId (`int`): The id of the fields list if applicable. Used for list fields and formula fields with list output type. - values (`list[Models.ListValue]`): The values of the fields list if applicable. Used for list fields and formula fields with list output type. - multiplicity (`str`): The multiplicity of the field if applicable. Used for list fields, reference fields, and formula fields with a list output type. - outputType (`str`): The output type of the field if applicable. Used for formula fields. - """ - - def __init__( - self, id: int, - appId: int, - name: str, - type: str, - status: str, - isRequired: bool, - isUnique: bool, - listId: int=None, - values: list[ListValue]=None, - multiplicity: str=None, - outputType: str=None - ): - - self.id:int = id - self.appId:int = appId - self.name:str = name - self.type:str = type - self.status:str = status - self.isRequired:bool = isRequired - self.isUnique:bool = isUnique - self.listId:int = listId - self.values:list[ListValue] = values - self.outputType:str = outputType - self.multiplicity:str = multiplicity - -class GetFieldByIdResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request an Onspring field. - - Attributes: - field (`Models.Field`): The requested field. - """ - - def __init__(self, field: Field): - self.field:Field = field - -class GetFieldsByIdsResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request a batch of Onspring fields. - - Attributes: - count (`int`): The number of fields requested. - fields (`list[Models.Field]`): The fields requested. - """ - - def __init__(self, count: int, fields: list[Field]): - self.count:int = count - self.fields:list[Field] = fields - -class GetFieldsByAppIdResponse: - """ - An object to represent a paginated response to a request made by an `OnspringClient` to request a collection of Onspring fields. - - Attributes: - pageNumber (`int`): The page number returned. - pageSize (`int`): The size of the page returned. - totalPages (`int`): The total number of pages for the request. - totalRecords (`int`): The total records for the request. - fields (`list[Models.Field]`): The fields requested. - """ - - def __init__(self, pageNumber: int, pageSize: int, totalPages: int, totalRecords: int, fields: list[Field]): - self.pageNumber:int = pageNumber - self.pageSize:int = pageSize - self.totalPages:int = totalPages - self.totalRecords:int = totalRecords - self.fields:list[Field] = fields - -# file specific - -class File: - """ - An object to represent a file stored in Onsprng. - - Attributes: - name (`str`): The name of the file. - contentType (`str`): The content type of the file. - contentLength (`int`): The length of the file's content. - content (`bytes`): The content of the file. - """ - - def __init__(self, name: str, contentType: str, contentLength: int, content: bytes): - self.name:str = name - self.contentType:str = contentType - self.contentLength:int = contentLength - self.content:bytes = content - -class FileInfo: - """ - An object to represent the metadata for a file stored in Onspring. - - Attributes: - type (`str`): The type of file as stored in Onspring Attachment or Image. - contentType (`str`): The content type of the file itself. - name (`str`): The name of the file - createdDate (`datetime`): The created date of the file. - modifiedDate (`datetime`): The modified date of the file. - owner (`str`): The owner of the file. - fileHref (`str`): The href for the file. - """ - - def __init__(self, type: str, contentType: str, name: str, createdDate: datetime, modifiedDate: datetime, owner: str, fileHref: str): - self.type:str = type - self.contentType:str = contentType - self.name:str = name - self.createdDate:datetime = createdDate - self.modifiedDate:datetime = modifiedDate - self.owner:str = owner - self.fileHref:str = fileHref - -class GetFileInfoByIdResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request a info for a file in Onspring. - - Attributes: - fileInfo (`Models.FileInfo`): The file info requested. - """ - - def __init__(self, fileInfo: FileInfo): - self.fileInfo:FileInfo = fileInfo - -class GetFileByIdResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request a file in Onspring. - - Attributes: - file (`Models.File`): The file requested. - """ - - def __init__(self, file: File): - self.file:File = file - -class SaveFileRequest: - """ - An object to represent all the necessary information needed to make a successful 'OnspringClient.SaveFile' request. - - Attributes: - recordId (`int`): The unique id of the record where you want to save the file. - fieldId (`int`): The unique id of the field where you want to save the file. - fileName (`str`): The name of the file you want to save. - filePath (`str`): The path to the file you want to save. - contentType (`str`): The content type of the file you want to save. - notes (`str`): An option note about the file. - notes (`datetime`): An optional date noting when the file was modified. - """ - def __init__(self, recordId: int, fieldId: int, fileName: str, filePath: str, contentType: str, notes: str=None, modifiedDate: datetime=None): - self.recordId:int = recordId - self.fieldId:int = fieldId - self.notes:str = notes - self.modifiedDate:datetime = modifiedDate - self.fileName:str = fileName - self.filePath:str = filePath - self.contentType:str = contentType - -class SaveFileResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to save a file in Onspring. - - Attributes: - id (`int`): The id of the file saved in Onspring. - """ - - def __init__(self, id: int): - self.id:int = id - -# list specific - -class ListItemRequest: - """ - An object to represent the necessary information for adding or updating a list value. If no id is provided the list value will be added. If an id is provided then an attempt will be made to find that list value and update it. - - Attributes: - listId (`int`): The id of the parent list that the list value belongs to. - name (`str`): The name of the list value. - id (`uuid`): The id of the list value. - numericValue (`int`): The numeric value assigned to the list value. - color (`str`): The color value assigned to the list value. - """ - - def __init__(self, listId: int, name: str, id: uuid.UUID=None, numericValue: int=None, color: str=None): - self.listId:int = listId - self.name:str = name - self.id:uuid.UUID = id - self.numericValue:int = numericValue - self.color:str = color - -class AddOrUpdateListItemResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to add or update a list value in Onspring. - - Attributes: - id (`int`): The id of the list value updated or added in Onspring. - """ - - def __init__(self, id: uuid.UUID): - self.id:uuid.UUID = id - -# record specific - -class TimeSpanData: - """ - An object to represent the data that makes up an Onspring timespan field. - - Attributes: - quantity (`Decimal`): - increment (`Enums.Increment`): - recurrence (`Enums.Recurrence`): - endByDate (`datetime`): - endAfterOccurrences (`int`): - """ - - def __init__(self, quantity: Decimal, increment: Increment, recurrence: Recurrence=None, endByDate: datetime=None, endAfterOccurrences: int=None): - self.quantity:Decimal = quantity - self.increment:Increment = increment - self.recurrence:Recurrence = recurrence - self.endByDate:datetime = endByDate - self.endAfterOccurrences:int = endAfterOccurrences - - def AsString(self) -> str: - if self.endByDate != None: - formattedDate = self.endByDate.strftime("%m/%d/%Y %I:%M %p") - return f'Every {self.quantity} {self.increment} End By {formattedDate}' - elif self.endAfterOccurrences != None: - return f'Every {self.quantity} {self.increment} End After {self.endAfterOccurrences}' - else: - return f'{self.quantity} {self.increment}' - -class Attachment: - """ - An object to represent an attachment in Onspring. - - Attributes: - fileId (`int`): The id of the file in Onspring. - fileName (`str`): The name of the file in Onspring. - notes (`str`): The notes for the file in Onspring. - storageLocation (`str`): The storage location of the file in Onspring. - """ - - def __init__(self, fileId: int, fileName: str, notes: str, storageLocation: str): - self.fileId:int = fileId - self.fileName:str = fileName - self.notes:str = notes - self.storageLocation:str = storageLocation - -class ScoringGroup: - """ - An object to represent an Onspring scoring group. - - Attributes: - listValueId (`UUID`): The id of the list value. - name (`str`): The name of the list value. - score (`Decimal`): The score for the list value. - maximumScore (`Decimal`): The maximum possible score for the group. - """ - - def __init__(self, listValueId: uuid.UUID, name: str, score: Decimal, maximumScore: Decimal): - self.listValueId:uuid.UUID = listValueId - self.name:str = name - self.score:Decimal = score - self.maximumScore:Decimal = maximumScore - -class RecordFieldValue: - """ - An object to represent the value in a field in an Onspring record. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`str`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value, type: str=None): - self.fieldId:int = fieldId - self.value = value - self.type:str = type - - def AsString(self) -> str | None: - """ - If the `Models.RecordFieldValue` type is String will return the value property as a `str` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `str`. - """ - - if self.type != ResultValueType.String.name: - return None - - return StringFieldValue(self.fieldId, self.value).value - - def AsInteger(self) -> int | None: - """ - If the `Models.RecordFieldValue` type is Integer will return the value property as an `int` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as an `int`. - """ - - if self.type != ResultValueType.Integer.name: - return None - - return IntegerFieldValue(self.fieldId, int(self.value)).value - - def AsDecimal(self) -> Decimal | None: - """ - If the `Models.RecordFieldValue` type is Decimal will return the value property as a `Decimal` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `Decimal`. - """ - - if self.type != ResultValueType.Decimal.name: - return None - - return DecimalFieldValue(self.fieldId, Decimal(self.value)).value - - def AsDate(self) -> datetime | None: - """ - If the `Models.RecordFieldValue` type is Date will return the value property as a `datetime` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `datetime`. - """ - - if self.type != ResultValueType.Date.name: - return None - - date = parseDate(self.value) - - return DateFieldValue(self.fieldId, date).value - - def AsGuid(self) -> uuid.UUID | None: - """ - If the `Models.RecordFieldValue` type is Guid will return the value property as an `UUID` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as an `UUID`. - """ - - if self.type != ResultValueType.Guid.name: - return None - - return GuidFieldValue(self.fieldId, uuid.UUID(self.value)).value - - def AsTimeSpan(self) -> TimeSpanData | None: - """ - If the `Models.RecordFieldValue` type is TimeSpan will return the value property as a `Model.TimeSpanData` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `Model.TimeSpanData`. - """ - - if self.type != ResultValueType.TimeSpan.name: - return None - - value = dict(self.value) - - quantity = value.get('quantity') - increment = value.get('increment') - recurrence = value.get('recurrence') - endByDate = value.get('endByDate') - endAfterOccurrences = value.get('endAfterOccurrences') - - endByDate = parseDate(endByDate) - - data = TimeSpanData( - quantity, - increment, - recurrence, - endByDate, - endAfterOccurrences) - - return TimeSpanValue(self.fieldId, data).value - - def AsStringList(self) -> list[str] | None: - """ - If the `Models.RecordFieldValue` type is StringList will return the value property as a `list[str]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[str]`. - """ - - if self.type != ResultValueType.StringList.name: - return None - - strings = [str(string) for string in self.value] - - return StringListValue(self.fieldId, strings).value - - def AsIntegerList(self) -> list[int] | None: - """ - If the `Models.RecordFieldValue` type is IntegerList will return the value property as a `list[int]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[int]`. - """ - - if self.type != ResultValueType.IntegerList.name: - return None - - integers = [int(integer) for integer in self.value] - - return IntegerListValue(self.fieldId, integers).value - - def AsGuidList(self) -> list[uuid.UUID] | None: - """ - If the `Models.RecordFieldValue` type is GuidList will return the value property as a `list[UUID]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[UUID]`. - """ - - if self.type != ResultValueType.GuidList.name: - return None - - guids = [] - - for guid in self.value: - guids.append(uuid.UUID(guid)) - - return GuidListValue(self.fieldId, guids).value - - def AsAttachmentList(self) -> list[Attachment] | None: - """ - If the `Models.RecordFieldValue` type is AttachmentList will return the value property as a `list[Models.Attachment]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[Models.Attachment]`. - """ - - if self.type != ResultValueType.AttachmentList.name: - return None - - attachments = [] - - for attachment in self.value: - - attachment = dict(attachment) - - attachment = Attachment( - attachment.get('fileId'), - attachment.get('fileName'), - attachment.get('notes'), - attachment.get('storageLocation')) - - attachments.append(attachment) - - return AttachmentListValue(self.fieldId, attachments).value - - def AsScoringGroupList(self) -> list[ScoringGroup] | None: - """ - If the `Models.RecordFieldValue` type is ScoringGroupList will return the value property as a `list[Models.ScoringGroup]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[Models.ScoringGroup]`. - """ - - if self.type != ResultValueType.ScoringGroupList.name: - return None - - scoringGroups = [] - - for scoringGroup in self.value: - - scoringGroup = dict(scoringGroup) - - scoringGroup = ScoringGroup( - uuid.UUID(scoringGroup.get('listValueId')), - scoringGroup.get('name'), - Decimal(scoringGroup.get('score')), - Decimal(scoringGroup.get('maximumScore'))) - - scoringGroups.append(scoringGroup) - - return ScoringGroupListValue(self.fieldId, scoringGroups).value - - def AsFileList(self) -> list[int] | None: - """ - If the `Models.RecordFieldValue` type is FileList will return the value property as a `list[int]` otherwise will return `None`. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a `list[int]`. - """ - - if self.type != ResultValueType.FileList.name: - return None - - files = [int(file) for file in self.value] - - return FileListValue(self.fieldId, files).value - - def getValue(self) -> object: - """ - Will determine the appropriate way to return the fields value based on it's type. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as the appropriate object. - """ - - if self.type == ResultValueType.String.name: - return self.AsString() - - elif self.type == ResultValueType.Integer.name: - return self.AsInteger() - - elif self.type == ResultValueType.Decimal.name: - return self.AsDecimal() - - elif self.type == ResultValueType.Date.name: - return self.AsDate() - - elif self.type == ResultValueType.TimeSpan.name: - return self.AsTimeSpan() - - elif self.type == ResultValueType.Guid.name: - return self.AsGuid() - - elif self.type == ResultValueType.StringList.name: - return self.AsStringList() - - elif self.type == ResultValueType.IntegerList.name: - return self.AsIntegerList() - - elif self.type == ResultValueType.GuidList.name: - return self.AsGuidList() - - elif self.type == ResultValueType.AttachmentList.name: - return self.AsAttachmentList() - - elif self.type == ResultValueType.ScoringGroupList.name: - return self.AsScoringGroupList() - - elif self.type == ResultValueType.FileList.name: - return self.AsFileList() - - else: - return None - - def GetResultValueString(self) -> str: - """ - Will return the value property regardless of the field value's type as a string. - - Args: - None - - Returns: - The value of the `Models.RecordFieldValue` as a string. - """ - if self.type == ResultValueType.String.name: - return self.AsString() - - elif self.type == ResultValueType.Integer.name: - return self.AsInteger() - - elif self.type == ResultValueType.Decimal.name: - return self.AsDecimal() - - elif self.type == ResultValueType.Date.name: - return self.AsDate() - - elif self.type == ResultValueType.TimeSpan.name: - data = self.AsTimeSpan() - return f'Quantity: {data.quantity}, Increment: {data.increment}, Recurrence: {data.recurrence}, EndByDate: {data.endByDate}, EndAfterOccurrences: {data.endAfterOccurrences}' - - elif self.type == ResultValueType.Guid.name: - return self.AsGuid() - - elif self.type == ResultValueType.StringList.name: - data = self.AsStringList() - return f'{",".join(data)}' - - elif self.type == ResultValueType.IntegerList.name: - data = self.AsIntegerList() - return f'{",".join([str(i) for i in data])}' - - elif self.type == ResultValueType.GuidList.name: - data = self.AsGuidList() - return f'{",".join([str(guid) for guid in data])}' - - elif self.type == ResultValueType.AttachmentList.name: - data = self.AsAttachmentList() - - strings = [] - - for attachment in data: - string = f'FileId: {attachment.fileId}, FileName: {attachment.fileName}, Notes: {attachment.notes}, StorageLocation: {attachment.storageLocation}' - strings.append(string) - - return f'{"; ".join(strings)}' - - elif self.type == ResultValueType.ScoringGroupList.name: - data = self.AsScoringGroupList() - - strings = [] - - for scoringGroup in data: - string = f'ListValueId: {scoringGroup.listValueId}, Name: {scoringGroup.name}, Score: {scoringGroup.score}, Max Score: {scoringGroup.maximumScore}' - strings.append(string) - - return f'{"; ".join(strings)}' - - elif self.type == ResultValueType.FileList.name: - data = self.AsFileList() - return f'{",".join([str(i) for i in data])}' - - else: - return None - -class Record: - """ - An object to represent an Onspring record. - - Attributes: - appId ('int'): The id of the Onspring app where the record resides. - recordId (`int`): The id of the Onspring record. - fields (`list[Models.RecordFieldValue]`): The record's field values. - """ - - def __init__(self, appId: int, fields: list[RecordFieldValue], recordId: int=None): - self.appId:int = appId - self.recordId:recordId = recordId - self.fields:list[RecordFieldValue] = fields - -class GetRecordsByAppRequest: - """ - An object to represent all the necessary information for making a succcessful request to get a collection of Onspring records. - - Attributes: - appId (`int`): The id for the Onspring app where the records reside. - fieldIds (`list[int]`): The ids for the fields in the Onspring app that should be included for each record in the response. - dataFormat (`str`): The format of the response data. - pagingRequest (`Models.PagingRequest`): Used to set the page number and page size of the request. By default the these will be 1 and 50 respectively. - """ - - def __init__(self, appId: int, fieldIds: list[int]=[], dataFormat: str=DataFormat.Raw.name, pagingRequest: PagingRequest=PagingRequest(1,50)): - self.appId:int = appId - self.fieldIds:list[int] = fieldIds - self.dataFormat:str = dataFormat - self.pageSize:int = pagingRequest.pageSize - self.pageNumber:int = pagingRequest.pageNumber - -class QueryRecordsRequest: - """ - An object to represent all the necessary information for making a succcessful request to get a collection of Onspring records based on a specific criteria. For more information on constructing a proper filter please refer to the official Onspring API guide: https://shorturl.at/cnsFK. - - Attributes: - appId (`int`): The id for the Onspring app where the records reside. - filter (`str`): The criteria used to determine what records should be included in the response. - fieldIds (`list[int]`): The ids for the fields in the Onspring app that should be included for each record in the response. - dataFormat (`str`): The format of the response data. - pagingRequest (`Models.PagingRequest`): Used to set the page number and page size of the request. By default the these will be 1 and 50 respectively. - """ - - def __init__(self, appId: int, filter: str, fieldIds: list[int]=[], dataFormat: str=DataFormat.Raw.name, pagingRequest: PagingRequest=PagingRequest(1,50)): - self.appId:int = appId - self.filter:str = filter - self.fieldIds:list[int] = fieldIds - self.dataFormat:str = dataFormat - self.pagingRequest:PagingRequest = pagingRequest - -class GetRecordsResponse: - """ - An object to represent a paginated response to a request made by an `OnspringClient` to request a collection of Onspring records. - - Attributes: - pageNumber (`int`): The page number returned. - pageSize (`int`): The size of the page returned. - totalPages (`int`): The total number of pages for the request. - totalRecords (`int`): The total records for the request. - records (`list[Models.Record]`): The records requested. - """ - - def __init__(self, pageNumber: int, pageSize: int, totalPages: int, totalRecords: int, records: list[Record]): - self.pageNumber:int = pageNumber - self.pageSize:int = pageSize - self.totalPages:int = totalPages - self.totalRecords:int = totalRecords - self.records:list[Record] = records - -class GetRecordByIdRequest: - """ - An object to represent all the necessary information for making a succcessful request to get an Onspring record by its id. - - Attributes: - appId (`int`): The id for the Onspring app where the record resides. - recordId (`int`): The id for the record being requested. - fieldIds (`list[int]`): The ids for the fields in the Onspring app that should be included for each record in the response. - dataFormat (`str`): The format of the response data. - """ - def __init__(self, appId: int, recordId: int, fieldIds: list[int]=[], dataFormat: str=DataFormat.Raw.name): - self.appId:int = appId - self.recordId:int = recordId - self.fieldIds:list[int] = fieldIds - self.dataFormat:str = dataFormat - -class GetBatchRecordsRequest: - """ - An object to represent all the necessary information for making a succcessful request to get a batch of Onspring records by their ids. - - Attributes: - appId (`int`): The id for the Onspring app where the records reside. - recordId (`list[int]`): The ids for the records being requested. - fieldIds (`list[int]`): The ids for the fields in the Onspring app that should be included for each record in the response. - dataFormat (`str`): The format of the response data. - """ - - def __init__(self, appId: int, recordIds: list[int], fieldIds: list[int]=[], dataFormat: str=DataFormat.Raw.name): - self.appId:int = appId - self.recordIds:list[int] = recordIds - self.fieldIds:list[int] = fieldIds - self.dataFormat:str = dataFormat - -class GetBatchRecordsResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to request a batch of Onspring records. - - Attributes: - count (`int`): The number of records requested. - records (`list[Models.Record]`): The records requested. - """ - - def __init__(self, count: int, records: list[Record]): - self.count:int = count - self.records:list[Record] = records - -class AddOrUpdateRecordResponse: - """ - An object to represent a response to a request made by an `OnspringClient` to add or update an Onspring record. - - Attributes: - id (`int`): The id of the Onspring record that was added or updated. - warnings ('list[str]'): A list of warnings. - """ - - def __init__(self, id: int, warnings: list[str]=[]): - self.id:int = id - self.warnings:list[str] = warnings - -class DeleteBatchRecordsRequest: - """ - An object to represent all the necessary information for making a succcessful request to delete a batch of Onspring records by their ids. - - Attributes: - appId (`int`): The id for the Onspring app where the records reside. - recordId (`list[int]`): The ids for the records being deleted. - """ - - def __init__(self, appId: int, recordIds: list[int]): - self.appId:int = appId - self.recordIds:list[int] = recordIds - -# field types - -class StringFieldValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the String type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`str`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value): - self.type:str = ResultValueType.String.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class IntegerFieldValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the Integer type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`int`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: int): - self.type:str = ResultValueType.Integer.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class DecimalFieldValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the Decimal type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`Decimal`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: Decimal): - self.type:str = ResultValueType.Decimal.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class DateFieldValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the Date type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`datetime`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: datetime): - self.type:str = ResultValueType.Date.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class GuidFieldValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the Guid type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`UUID`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: uuid.UUID): - self.type:str = ResultValueType.Guid.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class TimeSpanValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the TimeSpan type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`Models.TimeSpanData`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: TimeSpanData): - self.type:str = ResultValueType.TimeSpan.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class StringListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the StringList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[str]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[str]): - self.type:str = ResultValueType.StringList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class IntegerListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the IntegerList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[int]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[int]): - self.type:str = ResultValueType.IntegerList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class GuidListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the GuidList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[UUID]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[uuid.UUID]): - self.type:str = ResultValueType.GuidList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class AttachmentListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the AttachmentList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[Models.Attachment]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[Attachment]): - self.type:str = ResultValueType.AttachmentList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class FileListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the FileList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[int]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[int]): - self.type:str = ResultValueType.FileList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -class ScoringGroupListValue(RecordFieldValue): - """ - An object to represent an Onspring field value of the ScoringGroupList type. - - Attributes: - fieldId (`int`): The id of the field that the value is in. - value (`list[Models.ScoringGroup]`): The value of the field. - type (`str`): The type of value. - """ - - def __init__(self, fieldId: int, value: list[ScoringGroup]): - self.type:str = ResultValueType.ScoringGroupList.name - RecordFieldValue.__init__(self, fieldId, value, self.type) - -# report specific - -class GetReportByIdRequest: - """ - An object to represent the necessary information to make a successful request to get a report by it's id. - - Attributes: - reportId (`int`): The id of the report. - apiDataFormat (`str`): The format of the data in the report. - dataType (`str`): The data type for the report. - """ - - def __init__(self, reportId: int, apiDataFormat: str=DataFormat.Raw.name, dataType: str=ReportDataType.ReportData.name): - self.reportId:int = reportId - self.apiDataFormat:str = apiDataFormat - self.dataType:str = dataType - -class Row: - """ - An object to represent a row of an Onspring report. - - Attributes: - recordId (`int`): The id of the record who's data is held in the row. - cells (`list[str]`): The record field values held in the row. - """ - - def __init__(self, recordId: int, cells: list[str]): - self.recordId:int = recordId - self.cells:list[str] = cells - -class GetReportByIdResponse: - """ - An object to represent the response to a request made by an `OnspringClient` to get a report by its id. - - Attributes: - columns (`list[int]`): Values indicating the columns in the report. - rows (`list[Models.Row]`): A collection of rows representing the records in the report. - """ - - def __init__(self, columns: list[str], rows: list[Row]): - self.columns:list[str] = columns - self.rows:list[Row] = rows - -class Report: - """ - An object to represent an Onspring report. - - Attributes: - appId (`int`): The id of the app the report resides in. - id (`int`): The id of the report. - name (`str`): The name of the report. - description (`str`): The description for the report. - """ - - def __init__(self, appId: int, id: int, name: str, description: str): - self.appId:int = appId - self.id:int = id - self.name:str = name - self.description:str = description - -class GetReportsByAppIdResponse: - """ - An object to represent the response to a request made by an `OnspringClient` to get a collection of reports by an app id. - - Attributes: - pageNumber (`int`): The page number returned. - pageSize (`int`): The size of the page returned. - totalPages (`int`): The total number of pages for the request. - totalRecords (`int`): The total records for the request. - reports (`list[Models.Report]`): The requested reports for the current page. - """ - - def __init__(self, pageNumber: int, pageSize: int, totalPages: int, totalRecords: int, reports: list[Report]): - self.pageNumber:int = pageNumber - self.pageSize:int = pageSize - self.totalPages:int = totalPages - self.totalRecords:int = totalRecords - self.reports:list[Report] = reports - -# generic - -class ApiResponse: - """ - An object to represent a response to a request made by an `OnspringClient`. - - Attributes: - statusCode (`int`): The http status code of the response. - data: If the request was successful will contain the response data deserialized to custom python objects. - message (`str`): A message that may provide more detail about the requests success or failure. - raw (`requests.Response`): Exposes the raw response object of the request if you'd like to handle it directly. - """ - - def __init__( - self, - statusCode:int=None, - data: - GetAppsResponse| - GetAppByIdResponse| - GetAppsByIdsResponse| - GetFieldByIdResponse| - GetFieldsByIdsResponse| - GetFieldsByAppIdResponse| - GetFileInfoByIdResponse| - GetFileByIdResponse| - SaveFileResponse| - AddOrUpdateListItemResponse| - GetRecordsResponse| - Record| - GetBatchRecordsResponse| - AddOrUpdateRecordResponse| - GetReportByIdResponse| - GetReportsByAppIdResponse=None, - message:str=None, - raw:Response=None - ): - self.statusCode:int = statusCode - self.isSuccessful:bool = int(statusCode) < 400 - self.data = data - self.message:str = message - self.raw:Response = raw \ No newline at end of file diff --git a/src/OnspringApiSdk/OnspringClient.py b/src/OnspringApiSdk/OnspringClient.py deleted file mode 100644 index 246287c..0000000 --- a/src/OnspringApiSdk/OnspringClient.py +++ /dev/null @@ -1,1685 +0,0 @@ -import json -import re - -import requests - -from OnspringApiSdk.Endpoints import * -from OnspringApiSdk.Models import * - - -class OnspringClient: - """ - A class that represents a client that can interact with the api. - - Attributes: - baseUrl (`str`): The url that should be used as the base for all requests made by the client. - headers (`dict`): Contains key value pairs of the headers necessary for all requests made by the client including the necessary api key. - """ - - def __init__(self, url: str, key: str): - self.baseUrl = url - self.headers = { - 'x-apikey': key, - 'x-api-version': '2' - } - - # connectivity methods - - def CanConnect(self) -> bool: - """ - Verifies if the API is reachable by calling the ping endpoint. - - Args: - None - - Returns: - A `bool` value indicating if the API is responsive. - """ - endpoint = GetPingEndpoint(self.baseUrl) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers) - - return response.status_code == 200 - - # app methods - - def GetApps(self, pagingRequest=PagingRequest(1, 50)) -> ApiResponse: - """ - Gets all accessible apps for client. - - Parameters: - pagingRequest (`Models.PagingRequest`): Used to set the page number and page size of the request. By default the these will be 1 and 50 respectively. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetAppsEndpoint(self.baseUrl) - - params = pagingRequest.__dict__ - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 400: - return ApiResponse( - response.status_code, - message='Invalid paging information', - raw=response) - - if response.status_code == 401: - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - apps = [] - - for item in responseJson.get('items'): - - item = dict(item) - - app = App( - item.get('href'), - item.get('id'), - item.get('name')) - - apps.append(app) - - data = GetAppsResponse( - responseJson.get('pageNumber'), - responseJson.get('pageSize'), - responseJson.get('totalPages'), - responseJson.get('totalRecords'), - apps) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetAppById(self, appId: int) -> ApiResponse: - """ - Get an app by it's id. - - Parameters: - appId (`int`): The unique id of the app being requested. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetAppByIdEndpoint(self.baseUrl, appId) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - return ApiResponse( - response.status_code, - message='Client does not have read access to the app', - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='App could not be found', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - app = App( - responseJson.get('href'), - responseJson.get('id'), - responseJson.get('name')) - - data = GetAppByIdResponse(app) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetAppsByIds(self, appIds: list) -> ApiResponse: - """ - Get a set of apps by their ids. - - Parameters: - appIds (`list[int]`): A list of unique ids for the apps being requested. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetAppsByIdsEndpoint(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - # make sure appIds can be serialized to json string - if not isinstance(appIds, (list, tuple)): - return ApiResponse( - 400, - message='App ids should be of type list or tuple') - - appIds = json.dumps(appIds) - - response = requests.request( - 'POST', - endpoint, - headers=headers, - data=appIds) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - return ApiResponse( - response.status_code, - message='Client does not have read access to the app', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - apps = [] - - for item in responseJson['items']: - - item = dict(item) - - app = App( - item.get('href'), - item.get('id'), - item.get('name')) - - apps.append(app) - - data = GetAppsByIdsResponse( - responseJson.get('count'), - apps) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - # field methods - - def GetFieldById(self, fieldId: int) -> ApiResponse: - """ - Get a field by it's id. - - Parameters: - fieldId (`int`): The unique id of the field being requested. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetFieldByIdEndpoint(self.baseUrl, fieldId) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - return ApiResponse( - response.status_code, - message='Client does not have read access to the field', - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Field could not be found', - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - values = jsonResponse.get('values') - - if values != None: - - listValues = [] - - for value in values: - - value = dict(value) - - value = ListValue( - value.get('id'), - value.get('name'), - value.get('sortOrder'), - value.get('numericValue'), - value.get('color')) - - listValues.append(value) - - field = Field( - jsonResponse.get('id'), - jsonResponse.get('appId'), - jsonResponse.get('name'), - jsonResponse.get('type'), - jsonResponse.get('status'), - jsonResponse.get('isRequired'), - jsonResponse.get('isUnique'), - jsonResponse.get("listId"), - listValues, - jsonResponse.get('multiplicity'), - jsonResponse.get('outputType')) - - data = GetFieldByIdResponse(field) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetFieldsByIds(self, fieldIds: list) -> ApiResponse: - """ - Get a set of fields by their ids. - - Parameters: - fieldIds (`list[int]`): A list of unique ids for the fields being requested. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetFieldsByIdsEndpoint(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - # make sure fieldIds can be serialized to json string - if not isinstance(fieldIds, (list, tuple)): - return ApiResponse( - 400, - message='Field ids should be of type list or tuple') - - fieldIds = json.dumps(fieldIds) - - response = requests.request( - 'POST', - endpoint, - headers=headers, - data=fieldIds) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - return ApiResponse( - response.status_code, - message='Client does not have read access to the field(s)', - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Field(s) could not be found', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - fields = [] - - for item in responseJson.get('items'): - - item = dict(item) - - values = item.get('values') - - if values != None: - - listValues = [] - - for value in values: - - value = dict(value) - - value = ListValue( - value.get('id'), - value.get('name'), - value.get('sortOrder'), - value.get('numericValue'), - value.get('color')) - - listValues.append(value) - - field = Field( - item.get('id'), - item.get('appId'), - item.get('name'), - item.get('type'), - item.get('status'), - item.get('isRequired'), - item.get('isUnique'), - item.get("listId"), - listValues, - item.get('multiplicity'), - item.get('outputType')) - - fields.append(field) - - data = GetFieldsByIdsResponse( - responseJson.get('count'), - fields) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetFieldsByAppId(self, appId: int, pagingRequest=PagingRequest(1, 50)) -> ApiResponse: - """ - Get all fields for an app. - - Parameters: - appId (`int`): The unique id of the app whose fields are being requested. - pagingRequest (`Models.PagingRequest`): Used to set the page number and page size of the request. By default the these will be 1 and 50 respectively. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetFieldsByAppIdEndpoint(self.baseUrl, appId) - - params = pagingRequest.__dict__ - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Invalid paging information', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - fields = [] - - for item in responseJson.get('items'): - - item = dict(item) - - values = item.get('values') - listValues = [] - - if values != None: - - for value in values: - - value = dict(value) - - value = ListValue( - value.get('id'), - value.get('name'), - value.get('sortOrder'), - value.get('numericValue'), - value.get('color')) - - listValues.append(value) - - field = Field( - item.get('id'), - item.get('appId'), - item.get('name'), - item.get('type'), - item.get('status'), - item.get('isRequired'), - item.get('isUnique'), - item.get("listId"), - listValues, - item.get('multiplicity'), - item.get('outputType')) - - fields.append(field) - - data = GetFieldsByAppIdResponse( - responseJson.get('pageNumber'), - responseJson.get('pageSize'), - responseJson.get('totalPages'), - responseJson.get('totalRecords'), - fields) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - # file methods - - def GetFileInfoById(self, recordId: int, fieldId: int, fileId: int) -> ApiResponse: - """ - Get the metadata information for a file. - - Parameters: - recordId (`int`): The unique id of the record that contains the file you want to retrieve. - fieldId (`int`): The unique id of the field that contains the file you want to retrieve. - fileId (`int`): The unique id of the file whose metadata you want to retrieve. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetFileInfoByIdEndpoint( - self.baseUrl, - recordId, - fieldId, - fileId) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers,) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Request is invalid based on underlying data', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - return ApiResponse( - response.status_code, - message='Client does not have read access to the file', - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='File could not be found', - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - createdDate = parseDate(jsonResponse.get('createdDate')) - modifiedDate = parseDate(jsonResponse.get('modifiedDate')) - - fileInfo = FileInfo( - jsonResponse.get('type'), - jsonResponse.get('contentType'), - jsonResponse.get('name'), - createdDate, - modifiedDate, - jsonResponse.get('owner'), - jsonResponse.get('fileHref')) - - data = GetFileInfoByIdResponse(fileInfo) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def DeleteFileById(self, recordId: int, fieldId: int, fileId: int) -> ApiResponse: - """ - Delete a file by its id. - - Parameters: - recordId (`int`): The unique id of the record that contains the file you want to delete. - fieldId (`int`): The unique id of the field that contains the file you want to delete. - fileId (`int`): The unique id of the file you want to delete. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = DeleteFileByIdEndpoint( - self.baseUrl, - recordId, - fieldId, - fileId) - - response = requests.request( - 'DELETE', - endpoint, - headers=self.headers,) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Request is invalid based on underlying data', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403 or response.status_code == 404: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 500: - - return ApiResponse( - response.status_code, - message='File could not be deleted due to internal error', - raw=response) - - if response.status_code == 204: - - return ApiResponse( - response.status_code, - message='File deleted successfully', - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetFileById(self, recordId: int, fieldId: int, fileId: int) -> ApiResponse: - """ - Get a file by its id. - - Parameters: - recordId (`int`): The unique id of the record that contains the file you want to retrieve. - fieldId (`int`): The unique id of the field that contains the file you want to retrieve. - fileId (`int`): The unique id of the file you want to retrieve. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetFileByIdEndpoint( - self.baseUrl, - recordId, - fieldId, - fileId) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers,) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Request is invalid based on underlying data', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403 or response.status_code == 404: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 200: - - headers = dict(response.headers) - - fileName = headers.get('Content-Disposition') - result = re.search('filename=.*;', fileName).group() - - # TODO: implement attempting to build file name using content-type header - if result: - fileName = re.sub('filename=|\'|;', '', result) - else: - fileName = 'OnspringFile' - - file = File( - fileName, - headers.get('Content-Type'), - headers.get('Content-Length'), - response.content) - - data = GetFileByIdResponse(file) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def SaveFile(self, saveFileRequest: SaveFileRequest) -> ApiResponse: - """ - Delete a file by its id. - - Parameters: - saveFileRequest (`Models.SaveFileRequest`): An object representing all the necessary information to make a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = SaveFileEndpoint(self.baseUrl) - - files = [ - ( - 'File', - (saveFileRequest.fileName, open( - saveFileRequest.filePath, 'rb'), saveFileRequest.contentType) - ) - ] - - saveFileRequest = saveFileRequest.__dict__ - del saveFileRequest['fileName'] - del saveFileRequest['filePath'] - del saveFileRequest['contentType'] - - requestData = saveFileRequest - - response = requests.request( - 'POST', - endpoint, - headers=self.headers, - data=requestData, - files=files) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Request is invalid based on underlying data', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code in [403, 404, 500]: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 201: - - responseJson = dict(response.json()) - - data = SaveFileResponse(responseJson.get('id')) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - # list methods - - def AddOrUpdateListItem(self, listItemRequest: ListItemRequest) -> ApiResponse: - """ - Add or update a list value by its id. - - Parameters: - listItemRequest (`Models.ListItemRequest`): An object representing all the necessary information to make a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = AddOrUpdateListItemEndpoint( - self.baseUrl, listItemRequest.listId) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - del listItemRequest.__dict__['listId'] - - requestData = json.dumps(listItemRequest.__dict__) - - response = requests.request( - 'PUT', - endpoint, - headers=headers, - data=requestData) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code in [403, 404]: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 201: - - responseJson = dict(response.json()) - - data = AddOrUpdateListItemResponse(responseJson.get('id')) - - return ApiResponse( - response.status_code, - data, - message='New list value successfully added', - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - data = AddOrUpdateListItemResponse(responseJson.get('id')) - - return ApiResponse( - response.status_code, - data, - message='Existing list value successfully updated', - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def DeleteListItem(self, listId: int, itemId: str) -> ApiResponse: - """ - Delete a list value by its id and it's parent list id. - - Parameters: - listId (`int`): The unique id of the list values parent list. - itemId (`int`): The unique id of the list value to be deleted. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = DeleteListItemEndpoint(self.baseUrl, listId, itemId) - - response = requests.request( - 'DELETE', - endpoint, - headers=self.headers) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='List/item could not be found', - raw=response) - - if response.status_code == 204: - - return ApiResponse( - response.status_code, - message='Item deleted successfully', - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - # record methods - - def GetRecordsByAppId(self, getRecordsByAppRequest: GetRecordsByAppRequest) -> ApiResponse: - """ - Get all the records for an app by its id. - - Parameters: - getRecordsByAppRequest (`Models.GetRecordsByAppRequest`): The unique id of the list values parent list. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetRecordsByAppIdEndpoint( - self.baseUrl, getRecordsByAppRequest.appId) - - params = getRecordsByAppRequest.__dict__ - del params['appId'] - params['fieldIds'] = ",".join([str(i) for i in params['fieldIds']]) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Invalid paging information/size of the data requested was too large.', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - records = [] - - for item in jsonResponse.get('items'): - - item = dict(item) - - fields = [] - - record = Record( - item.get('appId'), - fields, - item.get('recordId')) - - for field in item.get('fieldData'): - - field = dict(field) - - field = RecordFieldValue( - field.get('fieldId'), - field.get('value'), - field.get('type')) - - fields.append(field) - - record.fields = fields - - records.append(record) - - data = GetRecordsResponse( - jsonResponse.get('pageNumber'), - jsonResponse.get('pageSize'), - jsonResponse.get('totalPages'), - jsonResponse.get('totalRecords'), - records) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetRecordById(self, getRecordByIdRequest: GetRecordByIdRequest) -> ApiResponse: - """ - Get a record by its id. - - Parameters: - getRecordByIdRequest (`Models.GetRecordByIdRequest`): An object that contains all the necessary information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetRecordByIdEndpoint( - self.baseUrl, getRecordByIdRequest.appId, getRecordByIdRequest.recordId) - - params = getRecordByIdRequest.__dict__ - del params['appId'] - del params['recordId'] - params['fieldIds'] = ",".join([str(i) for i in params['fieldIds']]) - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Record could not be found', - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - fields = [] - - for field in jsonResponse.get('fieldData'): - - field = dict(field) - - field = RecordFieldValue( - field.get('fieldId'), - field.get('value'), - field.get('type')) - - fields.append(field) - - data = Record( - jsonResponse.get('appId'), - fields, - jsonResponse.get('recordId')) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def DeleteRecordById(self, appId: int, recordId: int) -> ApiResponse: - """ - Delete a record by its id. - - Parameters: - appId (`int`): The id value of the app where the record you want to delete resides. - recordId (`int`): The id value of the record you want to delete. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = DeleteRecordByIdEndpoint(self.baseUrl, appId, recordId) - - response = requests.request( - 'DELETE', - endpoint, - headers=self.headers) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Record could not be found', - raw=response) - - if response.status_code == 204: - - return ApiResponse( - response.status_code, - message='Record deleted successfully', - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetRecordsByIds(self, getBatchRecordsRequest: GetBatchRecordsRequest) -> ApiResponse: - """ - Get records by their id. - - Parameters: - GetBatchRecordsRequest (`Models.GetBatchRecordsRequest`): An object that contains all the necessary information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetRecordsByIdsEndpoint(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - requestData = json.dumps(getBatchRecordsRequest.__dict__) - - response = requests.request( - 'POST', - endpoint, - headers=headers, - data=requestData) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Batch request is invalid/size of the data requested was too large.', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - records = [] - - for item in jsonResponse.get('items'): - - item = dict(item) - - fields = [] - - record = Record( - item.get('appId'), - fields, - item.get('recordId'),) - - for field in item.get('fieldData'): - - field = dict(field) - - field = RecordFieldValue( - field.get('fieldId'), - field.get('value'), - field.get('type')) - - fields.append(field) - - record.fields = fields - - records.append(record) - - data = GetBatchRecordsResponse( - jsonResponse.get('count'), - records) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def QueryRecords(self, queryRecordsRequest: QueryRecordsRequest) -> ApiResponse: - """ - Get records based on a criteria. - - Parameters: - queryRecordsRequest (`Models.QueryRecordsRequest`): An object that contains all the necessary information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = QueryRecordsEndpoint(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - requestData = queryRecordsRequest.__dict__ - - params = requestData.get('pagingRequest').__dict__ - - del requestData['pagingRequest'] - - requestData = json.dumps(requestData) - - response = requests.request( - 'POST', - endpoint, - headers=headers, - data=requestData, - params=params) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Query request is invalid/size of the data requested was too large.', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - records = [] - - for item in jsonResponse.get('items'): - - item = dict(item) - - fields = [] - - record = Record( - item.get('appId'), - fields, - item.get('recordId')) - - for field in item.get('fieldData'): - - field = dict(field) - - field = RecordFieldValue( - field.get('fieldId'), - field.get('value'), - field.get('type')) - - fields.append(field) - - record.fields = fields - - records.append(record) - - data = GetRecordsResponse( - jsonResponse.get('pageNumber'), - jsonResponse.get('pageSize'), - jsonResponse.get('totalPages'), - jsonResponse.get('totalRecords'), - records) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def AddOrUpdateRecord(self, record: Record) -> ApiResponse: - """ - Add a new record or update a record by its id. Not including an id adds a new record. - - Parameters: - record (`Models.Record`): An object that contains all the information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = AddOrUpdateRecordEndpoint(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - fieldsDict = {} - - for field in record.fields: - fieldsDict[field.fieldId] = field.value - - record.fields = fieldsDict - - requestData = json.dumps(record.__dict__, default=str) - - response = requests.request( - 'PUT', - endpoint, - headers=headers, - data=requestData) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Request is data is invalid', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code in [403, 404]: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code in [200, 201]: - - jsonResponse = dict(response.json()) - - data = AddOrUpdateRecordResponse( - jsonResponse.get('id'), - jsonResponse.get('warnings')) - - if response.status_code == 200: - message = 'Record updated successfully' - else: - message = 'Record created successfully' - - return ApiResponse( - response.status_code, - data, - message=message, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def DeleteRecordsByIds(self, deleteBatchRecordsRequest: DeleteBatchRecordsRequest) -> ApiResponse: - """ - Delete records by their ids. - - Parameters: - deleteBatchRecordsRequest (`Models.DeleteBatchRecordsRequest`): An object that contains all the necessary information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = DeleteRecordsByIds(self.baseUrl) - - headers = self.headers.copy() - headers['Content-Type'] = 'application/json' - - requestData = json.dumps(deleteBatchRecordsRequest.__dict__) - - response = requests.request( - 'POST', - endpoint, - headers=headers, - data=requestData) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Invalid request provided', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Records could not be found', - raw=response) - - if response.status_code == 204: - - return ApiResponse( - response.status_code, - message='Record(s) deleted successfully', - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - # report methods - - def GetReportById(self, getReportByIdRequest: GetReportByIdRequest) -> ApiResponse: - """ - Get a report by its id. - - Parameters: - getReportByIdRequest (`Models.GetReportByIdRequest`): An object that contains all the necessary information for making a successful request. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetReportByIdEndpoint( - self.baseUrl, getReportByIdRequest.reportId) - - params = getReportByIdRequest.__dict__ - del params['reportId'] - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 400: - - return ApiResponse( - response.status_code, - message='Invalid request based on underlying data', - raw=response) - - if response.status_code == 401: - - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 404: - - return ApiResponse( - response.status_code, - message='Report could not be found', - raw=response) - - if response.status_code == 200: - - jsonResponse = dict(response.json()) - - rows = [] - - for row in jsonResponse.get('rows'): - - row = dict(row) - - row = Row( - row.get('recordId'), - row.get('cells')) - - rows.append(row) - - data = GetReportByIdResponse( - jsonResponse.get('columns'), - rows) - - return ApiResponse( - response.status_code, - data=data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) - - def GetReportsByAppId(self, appId: int, pagingRequest: PagingRequest = PagingRequest(1, 50)) -> ApiResponse: - """ - Get reports for an app by its id.. - - Parameters: - appId (`int`): The unique id of the app whose reports are being requested. - pagingRequest (`Models.PagingRequest`): Used to set the page number and page size of the request. By default the these will be 1 and 50 respectively. - - Returns: - An ApiResponse (`Models.ApiResponse`) containing the results of the request. - """ - - endpoint = GetReportsByAppIdEndpoint(self.baseUrl, appId) - - params = pagingRequest.__dict__ - - response = requests.request( - 'GET', - endpoint, - headers=self.headers, - params=params) - - if response.status_code == 400: - return ApiResponse( - response.status_code, - message='Client does not have read access to the app.', - raw=response) - - if response.status_code == 401: - return ApiResponse( - response.status_code, - message='Unauthorized request', - raw=response) - - if response.status_code == 403: - - jsonResponse = dict(response.json()) - - return ApiResponse( - response.status_code, - message=jsonResponse.get('message'), - raw=response) - - if response.status_code == 200: - - responseJson = dict(response.json()) - - reports = [] - - for item in responseJson.get('items'): - - item = dict(item) - - report = Report( - item.get('appId'), - item.get('id'), - item.get('name'), - item.get('description')) - - reports.append(report) - - data = GetReportsByAppIdResponse( - responseJson.get('pageNumber'), - responseJson.get('pageSize'), - responseJson.get('totalPages'), - responseJson.get('totalRecords'), - reports) - - return ApiResponse( - response.status_code, - data, - raw=response) - - return ApiResponse( - response.status_code, - raw=response) diff --git a/src/onspring_api_sdk/__init__.py b/src/onspring_api_sdk/__init__.py new file mode 100644 index 0000000..a9e84d1 --- /dev/null +++ b/src/onspring_api_sdk/__init__.py @@ -0,0 +1,22 @@ +"""Onspring API SDK. + +Provides sync and async clients for interacting with the Onspring API v2. +""" + +from onspring_api_sdk.async_client import AsyncOnspringClient +from onspring_api_sdk.client import OnspringClient +from onspring_api_sdk.errors import ( + OnspringAuthenticationError, + OnspringError, + OnspringNotFoundError, + OnspringRateLimitError, +) + +__all__ = [ + "OnspringClient", + "AsyncOnspringClient", + "OnspringError", + "OnspringAuthenticationError", + "OnspringNotFoundError", + "OnspringRateLimitError", +] diff --git a/src/onspring_api_sdk/_responses.py b/src/onspring_api_sdk/_responses.py new file mode 100644 index 0000000..9ecf166 --- /dev/null +++ b/src/onspring_api_sdk/_responses.py @@ -0,0 +1,734 @@ +"""Shared response handlers for Onspring API endpoints.""" + +import re + +import httpx + +from onspring_api_sdk.errors import _get_error_message +from onspring_api_sdk.models import ( + AddOrUpdateListItemResponse, + AddOrUpdateRecordResponse, + ApiResponse, + App, + File, + FileInfo, + GetAppByIdResponse, + GetAppsByIdsResponse, + GetAppsResponse, + GetBatchRecordsResponse, + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + GetFileByIdResponse, + GetFileInfoByIdResponse, + GetRecordsResponse, + GetReportByIdResponse, + GetReportsByAppIdResponse, + OnspringField, + Record, + SaveFileResponse, +) + + +def handle_get_apps_response(response: httpx.Response) -> ApiResponse[GetAppsResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Invalid paging information", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetAppsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_field_by_id_response(response: httpx.Response) -> ApiResponse[GetFieldByIdResponse]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the field", + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Field could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetFieldByIdResponse(field=OnspringField.model_validate(response.json())), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_fields_by_ids_response(response: httpx.Response) -> ApiResponse[GetFieldsByIdsResponse]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the field(s)", + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Field(s) could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetFieldsByIdsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_fields_by_app_id_response(response: httpx.Response) -> ApiResponse[GetFieldsByAppIdResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Invalid paging information", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetFieldsByAppIdResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_apps_by_ids_response(response: httpx.Response) -> ApiResponse[GetAppsByIdsResponse]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the app", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetAppsByIdsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_app_by_id_response(response: httpx.Response) -> ApiResponse[GetAppByIdResponse]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the app", + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="App could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetAppByIdResponse(app=App.model_validate(response.json())), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_file_info_by_id_response(response: httpx.Response) -> ApiResponse[GetFileInfoByIdResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Request is invalid based on underlying data", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the file", + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="File could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetFileInfoByIdResponse(file_info=FileInfo.model_validate(response.json())), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_delete_file_by_id_response(response: httpx.Response) -> ApiResponse[None]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Request is invalid based on underlying data", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403 | 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 500: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="File could not be deleted due to internal error", + raw_response=response, + ) + case 204: + return ApiResponse( + status_code=response.status_code, message="File deleted successfully", raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_file_by_id_response(response: httpx.Response) -> ApiResponse[GetFileByIdResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Request is invalid based on underlying data", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403 | 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200: + headers = response.headers + content_disposition = headers.get("content-disposition", "") + match = re.search(r"filename=(.*?)(?:;|$)", content_disposition) + file_name = match.group(1).strip("'\"") if match else "OnspringFile" + file = File( + name=file_name, + contentType=headers.get("content-type", ""), + contentLength=int(headers.get("content-length", 0)), + content=response.content, + ) + + return ApiResponse( + status_code=response.status_code, data=GetFileByIdResponse(file=file), raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_save_file_response(response: httpx.Response) -> ApiResponse[SaveFileResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Request is invalid based on underlying data", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403 | 404 | 500: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 201: + return ApiResponse( + status_code=response.status_code, + data=SaveFileResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_add_or_update_list_item_response( + response: httpx.Response, +) -> ApiResponse[AddOrUpdateListItemResponse]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403 | 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 201: + return ApiResponse( + status_code=response.status_code, + data=AddOrUpdateListItemResponse.model_validate(response.json()), + message="New list value successfully added", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=AddOrUpdateListItemResponse.model_validate(response.json()), + message="Existing list value successfully updated", + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_delete_list_item_response(response: httpx.Response) -> ApiResponse[None]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="List/item could not be found", + raw_response=response, + ) + case 204: + return ApiResponse( + status_code=response.status_code, message="Item deleted successfully", raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_records_by_app_id_response(response: httpx.Response) -> ApiResponse[GetRecordsResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Invalid paging information/size of the data requested was too large.", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetRecordsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_record_by_id_response(response: httpx.Response) -> ApiResponse[Record]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Record could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, data=Record.model_validate(response.json()), raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_delete_record_by_id_response(response: httpx.Response) -> ApiResponse[None]: + match response.status_code: + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Record could not be found", + raw_response=response, + ) + case 204: + return ApiResponse( + status_code=response.status_code, message="Record deleted successfully", raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_records_by_ids_response(response: httpx.Response) -> ApiResponse[GetBatchRecordsResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Batch request is invalid/size of the data requested was too large.", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetBatchRecordsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_query_records_response(response: httpx.Response) -> ApiResponse[GetRecordsResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Query request is invalid/size of the data requested was too large.", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetRecordsResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_add_or_update_record_response(response: httpx.Response) -> ApiResponse[AddOrUpdateRecordResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Request data is invalid", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403 | 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200 | 201: + message = "Record updated successfully" if response.status_code == 200 else "Record created successfully" + return ApiResponse( + status_code=response.status_code, + data=AddOrUpdateRecordResponse.model_validate(response.json()), + message=message, + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_delete_records_by_ids_response(response: httpx.Response) -> ApiResponse[None]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Invalid request provided", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Records could not be found", + raw_response=response, + ) + case 204: + return ApiResponse( + status_code=response.status_code, message="Record(s) deleted successfully", raw_response=response + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_report_by_id_response(response: httpx.Response) -> ApiResponse[GetReportByIdResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Invalid request based on underlying data", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 404: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Report could not be found", + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetReportByIdResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) + + +def handle_get_reports_by_app_id_response(response: httpx.Response) -> ApiResponse[GetReportsByAppIdResponse]: + match response.status_code: + case 400: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Client does not have read access to the app.", + raw_response=response, + ) + case 401: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message="Unauthorized request", + raw_response=response, + ) + case 403: + return ApiResponse( + status_code=response.status_code, + is_successful=False, + message=_get_error_message(response), + raw_response=response, + ) + case 200: + return ApiResponse( + status_code=response.status_code, + data=GetReportsByAppIdResponse.model_validate(response.json()), + raw_response=response, + ) + case _: + return ApiResponse(status_code=response.status_code, is_successful=False, raw_response=response) diff --git a/src/onspring_api_sdk/async_client.py b/src/onspring_api_sdk/async_client.py new file mode 100644 index 0000000..8e3fb28 --- /dev/null +++ b/src/onspring_api_sdk/async_client.py @@ -0,0 +1,368 @@ +"""Async HTTP client for the Onspring API v2.""" + +import asyncio +import json +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +import httpx + +from onspring_api_sdk._responses import ( + handle_add_or_update_list_item_response, + handle_add_or_update_record_response, + handle_delete_file_by_id_response, + handle_delete_list_item_response, + handle_delete_record_by_id_response, + handle_delete_records_by_ids_response, + handle_get_app_by_id_response, + handle_get_apps_by_ids_response, + handle_get_apps_response, + handle_get_field_by_id_response, + handle_get_fields_by_app_id_response, + handle_get_fields_by_ids_response, + handle_get_file_by_id_response, + handle_get_file_info_by_id_response, + handle_get_record_by_id_response, + handle_get_records_by_app_id_response, + handle_get_records_by_ids_response, + handle_get_report_by_id_response, + handle_get_reports_by_app_id_response, + handle_query_records_response, + handle_save_file_response, +) +from onspring_api_sdk.endpoints import ( + add_or_update_list_item_endpoint, + add_or_update_record_endpoint, + delete_file_by_id_endpoint, + delete_list_item_endpoint, + delete_record_by_id_endpoint, + delete_records_by_ids_endpoint, + get_app_by_id_endpoint, + get_apps_by_ids_endpoint, + get_apps_endpoint, + get_field_by_id_endpoint, + get_fields_by_app_id_endpoint, + get_fields_by_ids_endpoint, + get_file_by_id_endpoint, + get_file_info_by_id_endpoint, + get_ping_endpoint, + get_record_by_id_endpoint, + get_records_by_app_id_endpoint, + get_records_by_ids_endpoint, + get_report_by_id_endpoint, + get_reports_by_app_id_endpoint, + query_records_endpoint, + save_file_endpoint, +) +from onspring_api_sdk.models import ( + AddOrUpdateListItemResponse, + AddOrUpdateRecordResponse, + ApiResponse, + DeleteBatchRecordsRequest, + GetAppByIdResponse, + GetAppsByIdsResponse, + GetAppsResponse, + GetBatchRecordsRequest, + GetBatchRecordsResponse, + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + GetFileByIdResponse, + GetFileInfoByIdResponse, + GetRecordByIdRequest, + GetRecordsByAppRequest, + GetRecordsResponse, + GetReportByIdRequest, + GetReportByIdResponse, + GetReportsByAppIdResponse, + ListItemRequest, + PagingRequest, + QueryRecordsRequest, + Record, + SaveFileRequest, + SaveFileResponse, +) + +API_VERSION = "2" +CONTENT_TYPE_JSON = "application/json" +_JSON_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"Content-Type": CONTENT_TYPE_JSON}) + + +class AsyncOnspringClient: + """Async client for interacting with the Onspring API v2.""" + + def __init__(self, url: str, key: str): + """Initialize the client with a base URL and API key.""" + self.client = httpx.AsyncClient( + headers={ + "x-apikey": key, + "x-api-version": API_VERSION, + } + ) + self.base_url = url + + async def aclose(self) -> None: + """Close the underlying HTTP client.""" + await self.client.aclose() + + async def __aenter__(self) -> "AsyncOnspringClient": + """Enter the async runtime context for the client.""" + return self + + async def __aexit__(self, *args) -> None: + """Exit the async runtime context and close the client.""" + await self.aclose() + + async def can_connect(self) -> bool: + """Ping the API to check connectivity.""" + response = await self.client.get(get_ping_endpoint(self.base_url)) + + return response.status_code == 200 + + async def get_apps(self, paging_request: PagingRequest | None = None) -> ApiResponse[GetAppsResponse]: + """Retrieve all apps the API key has access to.""" + if paging_request is None: + paging_request = PagingRequest() + + response = await self.client.get( + get_apps_endpoint(self.base_url), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_apps_response(response) + + async def get_app_by_id(self, app_id: int) -> ApiResponse[GetAppByIdResponse]: + """Retrieve an app by its ID.""" + response = await self.client.get(get_app_by_id_endpoint(self.base_url, app_id)) + + return handle_get_app_by_id_response(response) + + async def get_apps_by_ids(self, app_ids: list[int]) -> ApiResponse[GetAppsByIdsResponse]: + """Retrieve multiple apps by their IDs.""" + if not isinstance(app_ids, (list, tuple)): + return ApiResponse(status_code=400, is_successful=False, message="App ids should be of type list or tuple") + + response = await self.client.post( + get_apps_by_ids_endpoint(self.base_url), + content=json.dumps(app_ids), + headers=_JSON_HEADERS, + ) + + return handle_get_apps_by_ids_response(response) + + async def get_field_by_id(self, field_id: int) -> ApiResponse[GetFieldByIdResponse]: + """Retrieve a field by its ID.""" + response = await self.client.get(get_field_by_id_endpoint(self.base_url, field_id)) + + return handle_get_field_by_id_response(response) + + async def get_fields_by_ids(self, field_ids: list[int]) -> ApiResponse[GetFieldsByIdsResponse]: + """Retrieve multiple fields by their IDs.""" + if not isinstance(field_ids, (list, tuple)): + return ApiResponse( + status_code=400, is_successful=False, message="Field ids should be of type list or tuple" + ) + + response = await self.client.post( + get_fields_by_ids_endpoint(self.base_url), + content=json.dumps(field_ids), + headers=_JSON_HEADERS, + ) + + return handle_get_fields_by_ids_response(response) + + async def get_fields_by_app_id( + self, app_id: int, paging_request: PagingRequest | None = None + ) -> ApiResponse[GetFieldsByAppIdResponse]: + """Retrieve all fields for a given app.""" + if paging_request is None: + paging_request = PagingRequest() + + response = await self.client.get( + get_fields_by_app_id_endpoint(self.base_url, app_id), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_fields_by_app_id_response(response) + + async def get_file_info_by_id( + self, record_id: int, field_id: int, file_id: int + ) -> ApiResponse[GetFileInfoByIdResponse]: + """Retrieve file metadata for a file attached to a record.""" + response = await self.client.get(get_file_info_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_get_file_info_by_id_response(response) + + async def delete_file_by_id(self, record_id: int, field_id: int, file_id: int) -> ApiResponse[None]: + """Delete a file attached to a record.""" + response = await self.client.delete(delete_file_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_delete_file_by_id_response(response) + + async def get_file_by_id(self, record_id: int, field_id: int, file_id: int) -> ApiResponse[GetFileByIdResponse]: + """Download a file attached to a record.""" + response = await self.client.get(get_file_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_get_file_by_id_response(response) + + async def save_file(self, save_file_request: SaveFileRequest) -> ApiResponse[SaveFileResponse]: + """Upload a file to a record.""" + endpoint = save_file_endpoint(self.base_url) + + def _read_file() -> bytes: + with open(save_file_request.file_path, "rb") as f: + return f.read() + + file_content = await asyncio.to_thread(_read_file) + + files = { + "File": ( + save_file_request.file_name, + file_content, + save_file_request.content_type, + ), + } + + data = save_file_request.model_dump( + by_alias=True, exclude={"file_name", "file_path", "content_type"}, exclude_none=True + ) + + response = await self.client.post(endpoint, data=data, files=files) + + return handle_save_file_response(response) + + async def add_or_update_list_item( + self, list_item_request: ListItemRequest + ) -> ApiResponse[AddOrUpdateListItemResponse]: + """Add or update a list item value.""" + endpoint = add_or_update_list_item_endpoint(self.base_url, list_item_request.list_id) + payload = list_item_request.model_dump(by_alias=True, exclude={"list_id"}, exclude_none=True, mode="json") + + response = await self.client.put( + endpoint, + content=json.dumps(payload), + headers=_JSON_HEADERS, + ) + + return handle_add_or_update_list_item_response(response) + + async def delete_list_item(self, list_id: int, item_id: str) -> ApiResponse[None]: + """Delete a list item by its ID.""" + response = await self.client.delete(delete_list_item_endpoint(self.base_url, list_id, item_id)) + + return handle_delete_list_item_response(response) + + async def get_records_by_app_id(self, request: GetRecordsByAppRequest) -> ApiResponse[GetRecordsResponse]: + """Retrieve records from an app with optional filtering and paging.""" + params = request.model_dump(by_alias=True, exclude={"app_id"}, exclude_none=True) + field_ids = params.pop("fieldIds", None) + + if field_ids: + params["fieldIds"] = ",".join(str(i) for i in field_ids) + + response = await self.client.get( + get_records_by_app_id_endpoint(self.base_url, request.app_id), + params=params, + ) + + return handle_get_records_by_app_id_response(response) + + async def get_record_by_id(self, request: GetRecordByIdRequest) -> ApiResponse[Record]: + """Retrieve a single record by its ID.""" + params = request.model_dump(by_alias=True, exclude={"app_id", "record_id"}, exclude_none=True) + field_ids = params.pop("fieldIds", None) + + if field_ids: + params["fieldIds"] = ",".join(str(i) for i in field_ids) + + response = await self.client.get( + get_record_by_id_endpoint(self.base_url, request.app_id, request.record_id), + params=params, + ) + + return handle_get_record_by_id_response(response) + + async def delete_record_by_id(self, app_id: int, record_id: int) -> ApiResponse[None]: + """Delete a single record by its ID.""" + response = await self.client.delete(delete_record_by_id_endpoint(self.base_url, app_id, record_id)) + + return handle_delete_record_by_id_response(response) + + async def get_records_by_ids(self, request: GetBatchRecordsRequest) -> ApiResponse[GetBatchRecordsResponse]: + """Retrieve multiple records by their IDs.""" + response = await self.client.post( + get_records_by_ids_endpoint(self.base_url), + content=json.dumps(request.model_dump(by_alias=True, exclude_none=True, mode="json")), + headers=_JSON_HEADERS, + ) + + return handle_get_records_by_ids_response(response) + + async def query_records(self, request: QueryRecordsRequest) -> ApiResponse[GetRecordsResponse]: + """Query records using a structured query.""" + exclude = {"page_number", "page_size"} + payload = request.model_dump(by_alias=True, exclude=exclude, exclude_none=True, mode="json") + params = {"pageNumber": request.page_number, "pageSize": request.page_size} + + response = await self.client.post( + query_records_endpoint(self.base_url), + content=json.dumps(payload), + params=params, + headers=_JSON_HEADERS, + ) + + return handle_query_records_response(response) + + async def add_or_update_record(self, record: Record) -> ApiResponse[AddOrUpdateRecordResponse]: + """Add or update a record.""" + fields_dict = {} + + for field in record.fields: + fields_dict[field.field_id] = field.value + + payload = record.model_dump(by_alias=True, exclude={"fields"}, exclude_none=True, mode="json") + payload["fields"] = fields_dict + + response = await self.client.put( + add_or_update_record_endpoint(self.base_url), + content=json.dumps(payload, default=str), + headers=_JSON_HEADERS, + ) + + return handle_add_or_update_record_response(response) + + async def delete_records_by_ids(self, request: DeleteBatchRecordsRequest) -> ApiResponse[None]: + """Delete multiple records by their IDs.""" + response = await self.client.post( + delete_records_by_ids_endpoint(self.base_url), + content=json.dumps(request.model_dump(by_alias=True, exclude_none=True, mode="json")), + headers=_JSON_HEADERS, + ) + + return handle_delete_records_by_ids_response(response) + + async def get_report_by_id(self, request: GetReportByIdRequest) -> ApiResponse[GetReportByIdResponse]: + """Retrieve a report by its ID.""" + params = request.model_dump(by_alias=True, exclude={"report_id"}, exclude_none=True) + + response = await self.client.get( + get_report_by_id_endpoint(self.base_url, request.report_id), + params=params, + ) + + return handle_get_report_by_id_response(response) + + async def get_reports_by_app_id( + self, app_id: int, paging_request: PagingRequest | None = None + ) -> ApiResponse[GetReportsByAppIdResponse]: + """Retrieve all reports for a given app.""" + if paging_request is None: + paging_request = PagingRequest() + + response = await self.client.get( + get_reports_by_app_id_endpoint(self.base_url, app_id), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_reports_by_app_id_response(response) diff --git a/src/onspring_api_sdk/client.py b/src/onspring_api_sdk/client.py new file mode 100644 index 0000000..bc78806 --- /dev/null +++ b/src/onspring_api_sdk/client.py @@ -0,0 +1,359 @@ +"""Sync HTTP client for the Onspring API v2.""" + +import json +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +import httpx + +from onspring_api_sdk._responses import ( + handle_add_or_update_list_item_response, + handle_add_or_update_record_response, + handle_delete_file_by_id_response, + handle_delete_list_item_response, + handle_delete_record_by_id_response, + handle_delete_records_by_ids_response, + handle_get_app_by_id_response, + handle_get_apps_by_ids_response, + handle_get_apps_response, + handle_get_field_by_id_response, + handle_get_fields_by_app_id_response, + handle_get_fields_by_ids_response, + handle_get_file_by_id_response, + handle_get_file_info_by_id_response, + handle_get_record_by_id_response, + handle_get_records_by_app_id_response, + handle_get_records_by_ids_response, + handle_get_report_by_id_response, + handle_get_reports_by_app_id_response, + handle_query_records_response, + handle_save_file_response, +) +from onspring_api_sdk.endpoints import ( + add_or_update_list_item_endpoint, + add_or_update_record_endpoint, + delete_file_by_id_endpoint, + delete_list_item_endpoint, + delete_record_by_id_endpoint, + delete_records_by_ids_endpoint, + get_app_by_id_endpoint, + get_apps_by_ids_endpoint, + get_apps_endpoint, + get_field_by_id_endpoint, + get_fields_by_app_id_endpoint, + get_fields_by_ids_endpoint, + get_file_by_id_endpoint, + get_file_info_by_id_endpoint, + get_ping_endpoint, + get_record_by_id_endpoint, + get_records_by_app_id_endpoint, + get_records_by_ids_endpoint, + get_report_by_id_endpoint, + get_reports_by_app_id_endpoint, + query_records_endpoint, + save_file_endpoint, +) +from onspring_api_sdk.models import ( + AddOrUpdateListItemResponse, + AddOrUpdateRecordResponse, + ApiResponse, + DeleteBatchRecordsRequest, + GetAppByIdResponse, + GetAppsByIdsResponse, + GetAppsResponse, + GetBatchRecordsRequest, + GetBatchRecordsResponse, + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + GetFileByIdResponse, + GetFileInfoByIdResponse, + GetRecordByIdRequest, + GetRecordsByAppRequest, + GetRecordsResponse, + GetReportByIdRequest, + GetReportByIdResponse, + GetReportsByAppIdResponse, + ListItemRequest, + PagingRequest, + QueryRecordsRequest, + Record, + SaveFileRequest, + SaveFileResponse, +) + +API_VERSION = "2" +CONTENT_TYPE_JSON = "application/json" +_JSON_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"Content-Type": CONTENT_TYPE_JSON}) + + +class OnspringClient: + """Sync client for interacting with the Onspring API v2.""" + + def __init__(self, url: str, key: str): + """Initialize the client with a base URL and API key.""" + self.client = httpx.Client( + headers={ + "x-apikey": key, + "x-api-version": API_VERSION, + } + ) + self.base_url = url + + def close(self) -> None: + """Close the underlying HTTP client.""" + self.client.close() + + def __enter__(self) -> "OnspringClient": + """Enter the runtime context for the client.""" + return self + + def __exit__(self, *args) -> None: + """Exit the runtime context and close the client.""" + self.close() + + def can_connect(self) -> bool: + """Ping the API to check connectivity.""" + response = self.client.get(get_ping_endpoint(self.base_url)) + + return response.status_code == 200 + + def get_apps(self, paging_request: PagingRequest | None = None) -> ApiResponse[GetAppsResponse]: + """Get all apps with optional paging.""" + if paging_request is None: + paging_request = PagingRequest() + + response = self.client.get( + get_apps_endpoint(self.base_url), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_apps_response(response) + + def get_app_by_id(self, app_id: int) -> ApiResponse[GetAppByIdResponse]: + """Get an app by its ID.""" + response = self.client.get(get_app_by_id_endpoint(self.base_url, app_id)) + + return handle_get_app_by_id_response(response) + + def get_apps_by_ids(self, app_ids: list[int]) -> ApiResponse[GetAppsByIdsResponse]: + """Get multiple apps by their IDs.""" + if not isinstance(app_ids, (list, tuple)): + return ApiResponse(status_code=400, is_successful=False, message="App ids should be of type list or tuple") + + response = self.client.post( + get_apps_by_ids_endpoint(self.base_url), + content=json.dumps(app_ids), + headers=_JSON_HEADERS, + ) + + return handle_get_apps_by_ids_response(response) + + def get_field_by_id(self, field_id: int) -> ApiResponse[GetFieldByIdResponse]: + """Get a field by its ID.""" + response = self.client.get(get_field_by_id_endpoint(self.base_url, field_id)) + + return handle_get_field_by_id_response(response) + + def get_fields_by_ids(self, field_ids: list[int]) -> ApiResponse[GetFieldsByIdsResponse]: + """Get multiple fields by their IDs.""" + if not isinstance(field_ids, (list, tuple)): + return ApiResponse( + status_code=400, is_successful=False, message="Field ids should be of type list or tuple" + ) + + response = self.client.post( + get_fields_by_ids_endpoint(self.base_url), + content=json.dumps(field_ids), + headers=_JSON_HEADERS, + ) + + return handle_get_fields_by_ids_response(response) + + def get_fields_by_app_id( + self, app_id: int, paging_request: PagingRequest | None = None + ) -> ApiResponse[GetFieldsByAppIdResponse]: + """Get all fields for an app with optional paging.""" + if paging_request is None: + paging_request = PagingRequest() + + response = self.client.get( + get_fields_by_app_id_endpoint(self.base_url, app_id), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_fields_by_app_id_response(response) + + def get_file_info_by_id(self, record_id: int, field_id: int, file_id: int) -> ApiResponse[GetFileInfoByIdResponse]: + """Get file metadata by record, field, and file IDs.""" + response = self.client.get(get_file_info_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_get_file_info_by_id_response(response) + + def delete_file_by_id(self, record_id: int, field_id: int, file_id: int) -> ApiResponse[None]: + """Delete a file by record, field, and file IDs.""" + response = self.client.delete(delete_file_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_delete_file_by_id_response(response) + + def get_file_by_id(self, record_id: int, field_id: int, file_id: int) -> ApiResponse[GetFileByIdResponse]: + """Get a file by record, field, and file IDs.""" + response = self.client.get(get_file_by_id_endpoint(self.base_url, record_id, field_id, file_id)) + + return handle_get_file_by_id_response(response) + + def save_file(self, save_file_request: SaveFileRequest) -> ApiResponse[SaveFileResponse]: + """Save a file to a record.""" + endpoint = save_file_endpoint(self.base_url) + + with open(save_file_request.file_path, "rb") as f: + file_content = f.read() + + files = { + "File": ( + save_file_request.file_name, + file_content, + save_file_request.content_type, + ), + } + + data = save_file_request.model_dump( + by_alias=True, exclude={"file_name", "file_path", "content_type"}, exclude_none=True + ) + + response = self.client.post(endpoint, data=data, files=files) + + return handle_save_file_response(response) + + def add_or_update_list_item(self, list_item_request: ListItemRequest) -> ApiResponse[AddOrUpdateListItemResponse]: + """Add or update a list item value.""" + endpoint = add_or_update_list_item_endpoint(self.base_url, list_item_request.list_id) + payload = list_item_request.model_dump(by_alias=True, exclude={"list_id"}, exclude_none=True, mode="json") + + response = self.client.put( + endpoint, + content=json.dumps(payload), + headers=_JSON_HEADERS, + ) + + return handle_add_or_update_list_item_response(response) + + def delete_list_item(self, list_id: int, item_id: str) -> ApiResponse[None]: + """Delete a list item by list and item IDs.""" + response = self.client.delete(delete_list_item_endpoint(self.base_url, list_id, item_id)) + + return handle_delete_list_item_response(response) + + def get_records_by_app_id(self, request: GetRecordsByAppRequest) -> ApiResponse[GetRecordsResponse]: + """Get records for an app with optional filtering and paging.""" + params = request.model_dump(by_alias=True, exclude={"app_id"}, exclude_none=True) + field_ids = params.pop("fieldIds", None) + if field_ids: + params["fieldIds"] = ",".join(str(i) for i in field_ids) + + response = self.client.get( + get_records_by_app_id_endpoint(self.base_url, request.app_id), + params=params, + ) + + return handle_get_records_by_app_id_response(response) + + def get_record_by_id(self, request: GetRecordByIdRequest) -> ApiResponse[Record]: + """Get a record by its app and record IDs.""" + params = request.model_dump(by_alias=True, exclude={"app_id", "record_id"}, exclude_none=True) + field_ids = params.pop("fieldIds", None) + + if field_ids: + params["fieldIds"] = ",".join(str(i) for i in field_ids) + + response = self.client.get( + get_record_by_id_endpoint(self.base_url, request.app_id, request.record_id), + params=params, + ) + + return handle_get_record_by_id_response(response) + + def delete_record_by_id(self, app_id: int, record_id: int) -> ApiResponse[None]: + """Delete a record by its app and record IDs.""" + response = self.client.delete(delete_record_by_id_endpoint(self.base_url, app_id, record_id)) + + return handle_delete_record_by_id_response(response) + + def get_records_by_ids(self, request: GetBatchRecordsRequest) -> ApiResponse[GetBatchRecordsResponse]: + """Get multiple records by their IDs.""" + response = self.client.post( + get_records_by_ids_endpoint(self.base_url), + content=json.dumps(request.model_dump(by_alias=True, exclude_none=True, mode="json")), + headers=_JSON_HEADERS, + ) + + return handle_get_records_by_ids_response(response) + + def query_records(self, request: QueryRecordsRequest) -> ApiResponse[GetRecordsResponse]: + """Query records using a structured query request.""" + exclude = {"page_number", "page_size"} + payload = request.model_dump(by_alias=True, exclude=exclude, exclude_none=True, mode="json") + params = {"pageNumber": request.page_number, "pageSize": request.page_size} + + response = self.client.post( + query_records_endpoint(self.base_url), + content=json.dumps(payload), + params=params, + headers=_JSON_HEADERS, + ) + + return handle_query_records_response(response) + + def add_or_update_record(self, record: Record) -> ApiResponse[AddOrUpdateRecordResponse]: + """Add or update a record.""" + fields_dict = {} + + for field in record.fields: + fields_dict[field.field_id] = field.value + + payload = record.model_dump(by_alias=True, exclude={"fields"}, exclude_none=True, mode="json") + payload["fields"] = fields_dict + + response = self.client.put( + add_or_update_record_endpoint(self.base_url), + content=json.dumps(payload, default=str), + headers=_JSON_HEADERS, + ) + + return handle_add_or_update_record_response(response) + + def delete_records_by_ids(self, request: DeleteBatchRecordsRequest) -> ApiResponse[None]: + """Delete multiple records by their IDs.""" + response = self.client.post( + delete_records_by_ids_endpoint(self.base_url), + content=json.dumps(request.model_dump(by_alias=True, exclude_none=True, mode="json")), + headers=_JSON_HEADERS, + ) + + return handle_delete_records_by_ids_response(response) + + def get_report_by_id(self, request: GetReportByIdRequest) -> ApiResponse[GetReportByIdResponse]: + """Get a report by its ID.""" + params = request.model_dump(by_alias=True, exclude={"report_id"}, exclude_none=True) + + response = self.client.get( + get_report_by_id_endpoint(self.base_url, request.report_id), + params=params, + ) + + return handle_get_report_by_id_response(response) + + def get_reports_by_app_id( + self, app_id: int, paging_request: PagingRequest | None = None + ) -> ApiResponse[GetReportsByAppIdResponse]: + """Get all reports for an app with optional paging.""" + if paging_request is None: + paging_request = PagingRequest() + + response = self.client.get( + get_reports_by_app_id_endpoint(self.base_url, app_id), + params=paging_request.model_dump(by_alias=True, exclude_none=True), + ) + + return handle_get_reports_by_app_id_response(response) diff --git a/src/onspring_api_sdk/endpoints.py b/src/onspring_api_sdk/endpoints.py new file mode 100644 index 0000000..3287b10 --- /dev/null +++ b/src/onspring_api_sdk/endpoints.py @@ -0,0 +1,111 @@ +"""URL builder functions for all Onspring API v2 endpoints.""" + + +def get_ping_endpoint(base_url: str) -> str: + """Build the ping endpoint URL.""" + return f"{base_url}/Ping" + + +def get_apps_endpoint(base_url: str) -> str: + """Build the get-apps endpoint URL.""" + return f"{base_url}/Apps" + + +def get_app_by_id_endpoint(base_url: str, app_id: int) -> str: + """Build the get-app-by-id endpoint URL.""" + return f"{base_url}/Apps/id/{app_id}" + + +def get_apps_by_ids_endpoint(base_url: str) -> str: + """Build the get-apps-by-ids endpoint URL.""" + return f"{base_url}/Apps/batch-get" + + +def get_field_by_id_endpoint(base_url: str, field_id: int) -> str: + """Build the get-field-by-id endpoint URL.""" + return f"{base_url}/Fields/id/{field_id}" + + +def get_fields_by_ids_endpoint(base_url: str) -> str: + """Build the get-fields-by-ids endpoint URL.""" + return f"{base_url}/Fields/batch-get" + + +def get_fields_by_app_id_endpoint(base_url: str, app_id: int) -> str: + """Build the get-fields-by-app-id endpoint URL.""" + return f"{base_url}/Fields/appId/{app_id}" + + +def get_file_info_by_id_endpoint(base_url: str, record_id: int, field_id: int, file_id: int) -> str: + """Build the get-file-info-by-id endpoint URL.""" + return f"{base_url}/Files/recordId/{record_id}/fieldId/{field_id}/fileId/{file_id}" + + +def delete_file_by_id_endpoint(base_url: str, record_id: int, field_id: int, file_id: int) -> str: + """Build the delete-file-by-id endpoint URL.""" + return f"{base_url}/Files/recordId/{record_id}/fieldId/{field_id}/fileId/{file_id}" + + +def get_file_by_id_endpoint(base_url: str, record_id: int, field_id: int, file_id: int) -> str: + """Build the get-file-by-id endpoint URL.""" + return f"{base_url}/Files/recordId/{record_id}/fieldId/{field_id}/fileId/{file_id}/file" + + +def save_file_endpoint(base_url: str) -> str: + """Build the save-file endpoint URL.""" + return f"{base_url}/Files" + + +def add_or_update_list_item_endpoint(base_url: str, list_id: int) -> str: + """Build the add-or-update-list-item endpoint URL.""" + return f"{base_url}/Lists/id/{list_id}/items" + + +def delete_list_item_endpoint(base_url: str, list_id: int, item_id: str) -> str: + """Build the delete-list-item endpoint URL.""" + return f"{base_url}/Lists/id/{list_id}/itemId/{item_id}" + + +def get_records_by_app_id_endpoint(base_url: str, app_id: int) -> str: + """Build the get-records-by-app-id endpoint URL.""" + return f"{base_url}/Records/appId/{app_id}" + + +def get_record_by_id_endpoint(base_url: str, app_id: int, record_id: int) -> str: + """Build the get-record-by-id endpoint URL.""" + return f"{base_url}/Records/appId/{app_id}/recordId/{record_id}" + + +def delete_record_by_id_endpoint(base_url: str, app_id: int, record_id: int) -> str: + """Build the delete-record-by-id endpoint URL.""" + return f"{base_url}/Records/appId/{app_id}/recordId/{record_id}" + + +def get_records_by_ids_endpoint(base_url: str) -> str: + """Build the get-records-by-ids endpoint URL.""" + return f"{base_url}/Records/batch-get" + + +def query_records_endpoint(base_url: str) -> str: + """Build the query-records endpoint URL.""" + return f"{base_url}/Records/Query" + + +def add_or_update_record_endpoint(base_url: str) -> str: + """Build the add-or-update-record endpoint URL.""" + return f"{base_url}/Records" + + +def delete_records_by_ids_endpoint(base_url: str) -> str: + """Build the delete-records-by-ids endpoint URL.""" + return f"{base_url}/Records/batch-delete" + + +def get_report_by_id_endpoint(base_url: str, report_id: int) -> str: + """Build the get-report-by-id endpoint URL.""" + return f"{base_url}/Reports/id/{report_id}" + + +def get_reports_by_app_id_endpoint(base_url: str, app_id: int) -> str: + """Build the get-reports-by-app-id endpoint URL.""" + return f"{base_url}/Reports/appId/{app_id}" diff --git a/src/onspring_api_sdk/enums.py b/src/onspring_api_sdk/enums.py new file mode 100644 index 0000000..ebd8c44 --- /dev/null +++ b/src/onspring_api_sdk/enums.py @@ -0,0 +1,54 @@ +"""Enumerations for Onspring API data types and timespan configuration.""" + +from enum import Enum + + +class DataFormat(Enum): + """The possible data format types for record field values.""" + + Raw: int = 0 + Formatted: int = 1 + + +class ReportDataType(Enum): + """The possible report data types for reports.""" + + ReportData: int = 0 + ChartData: int = 1 + + +class ResultValueType(Enum): + """The possible types for record field values.""" + + String: int = 0 + Integer: int = 1 + Decimal: int = 2 + Date: int = 3 + TimeSpan: int = 4 + Guid: int = 5 + StringList: int = 6 + IntegerList: int = 7 + GuidList: int = 8 + AttachmentList: int = 9 + ScoringGroupList: int = 10 + FileList: int = 11 + + +class Increment(Enum): + """Possible increment values for timespan data in an Onspring timespan field.""" + + Seconds: str = "Second(s)" + Minutes: str = "Minute(s)" + Hours: str = "Hour(s)" + Days: str = "Day(s)" + Weeks: str = "Week(s)" + Months: str = "Month(s)" + Years: str = "Year(s)" + + +class Recurrence(Enum): + """Possible recurrence values for timespan data in an Onspring timespan field.""" + + Empty: str = "None" + EndByDate: str = "EndByDate" + EndAfterOccurrences: str = "EndAfterOccurrences" diff --git a/src/onspring_api_sdk/errors.py b/src/onspring_api_sdk/errors.py new file mode 100644 index 0000000..7a5d805 --- /dev/null +++ b/src/onspring_api_sdk/errors.py @@ -0,0 +1,32 @@ +"""Custom exception hierarchy for Onspring API errors.""" + +import json + +import httpx + + +def _get_error_message(response: httpx.Response) -> str | None: + """Safely extract an error message from an API response body.""" + try: + body = response.json() + if isinstance(body, dict): + return body.get("message") + except (json.JSONDecodeError, ValueError, AttributeError): + pass + return None + + +class OnspringError(Exception): + """Base exception for all Onspring API errors.""" + + +class OnspringAuthenticationError(OnspringError): + """Raised when the API returns a 401 or 403 status code.""" + + +class OnspringNotFoundError(OnspringError): + """Raised when the API returns a 404 status code.""" + + +class OnspringRateLimitError(OnspringError): + """Raised when the API returns a 429 status code.""" diff --git a/src/onspring_api_sdk/models/__init__.py b/src/onspring_api_sdk/models/__init__.py new file mode 100644 index 0000000..50024cd --- /dev/null +++ b/src/onspring_api_sdk/models/__init__.py @@ -0,0 +1,106 @@ +"""Re-exports for all Pydantic models used by the Onspring API SDK.""" + +from onspring_api_sdk.models.app import App, GetAppByIdResponse, GetAppsByIdsResponse, GetAppsResponse +from onspring_api_sdk.models.common import ApiResponse, PagingRequest +from onspring_api_sdk.models.field import ( + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + ListValue, + OnspringField, +) +from onspring_api_sdk.models.file import ( + File, + FileInfo, + GetFileByIdResponse, + GetFileInfoByIdResponse, + SaveFileRequest, + SaveFileResponse, +) +from onspring_api_sdk.models.list import AddOrUpdateListItemResponse, ListItemRequest +from onspring_api_sdk.models.record import ( + AddOrUpdateRecordResponse, + Attachment, + AttachmentListValue, + DateFieldValue, + DecimalFieldValue, + DeleteBatchRecordsRequest, + FileListValue, + GetBatchRecordsRequest, + GetBatchRecordsResponse, + GetRecordByIdRequest, + GetRecordsByAppRequest, + GetRecordsResponse, + GuidFieldValue, + GuidListValue, + IntegerFieldValue, + IntegerListValue, + QueryRecordsRequest, + Record, + RecordFieldValue, + ScoringGroup, + ScoringGroupListValue, + StringFieldValue, + StringListValue, + TimeSpanData, + TimeSpanValue, +) +from onspring_api_sdk.models.report import ( + GetReportByIdRequest, + GetReportByIdResponse, + GetReportsByAppIdResponse, + Report, + Row, +) + +__all__ = [ + "ApiResponse", + "PagingRequest", + "App", + "GetAppsResponse", + "GetAppByIdResponse", + "GetAppsByIdsResponse", + "ListValue", + "OnspringField", + "GetFieldByIdResponse", + "GetFieldsByIdsResponse", + "GetFieldsByAppIdResponse", + "RecordFieldValue", + "StringFieldValue", + "IntegerFieldValue", + "DecimalFieldValue", + "DateFieldValue", + "GuidFieldValue", + "TimeSpanData", + "TimeSpanValue", + "StringListValue", + "IntegerListValue", + "GuidListValue", + "Attachment", + "AttachmentListValue", + "ScoringGroup", + "ScoringGroupListValue", + "FileListValue", + "Record", + "GetRecordsByAppRequest", + "QueryRecordsRequest", + "GetRecordsResponse", + "GetRecordByIdRequest", + "GetBatchRecordsRequest", + "GetBatchRecordsResponse", + "AddOrUpdateRecordResponse", + "DeleteBatchRecordsRequest", + "Row", + "Report", + "GetReportByIdRequest", + "GetReportByIdResponse", + "GetReportsByAppIdResponse", + "File", + "FileInfo", + "GetFileInfoByIdResponse", + "GetFileByIdResponse", + "SaveFileRequest", + "SaveFileResponse", + "ListItemRequest", + "AddOrUpdateListItemResponse", +] diff --git a/src/onspring_api_sdk/models/app.py b/src/onspring_api_sdk/models/app.py new file mode 100644 index 0000000..9686cc5 --- /dev/null +++ b/src/onspring_api_sdk/models/app.py @@ -0,0 +1,38 @@ +"""Pydantic models for Onspring app API responses.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class App(BaseModel): + """Represents an Onspring app.""" + + href: str + id: int + name: str + + +class GetAppsResponse(BaseModel): + """Paginated response containing a list of apps.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int = Field(alias="pageNumber") + page_size: int = Field(alias="pageSize") + total_pages: int = Field(alias="totalPages") + total_records: int = Field(alias="totalRecords") + apps: list[App] = Field(alias="items") + + +class GetAppByIdResponse(BaseModel): + """Response containing a single app.""" + + app: App + + +class GetAppsByIdsResponse(BaseModel): + """Response containing a list of apps for requested IDs.""" + + model_config = ConfigDict(populate_by_name=True) + + count: int + apps: list[App] = Field(alias="items") diff --git a/src/onspring_api_sdk/models/common.py b/src/onspring_api_sdk/models/common.py new file mode 100644 index 0000000..3c5f1bc --- /dev/null +++ b/src/onspring_api_sdk/models/common.py @@ -0,0 +1,55 @@ +"""Shared Pydantic models for paging and generic API responses.""" + +from typing import Generic, Optional, TypeVar + +from httpx import Response +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from onspring_api_sdk.errors import ( + OnspringAuthenticationError, + OnspringError, + OnspringNotFoundError, + OnspringRateLimitError, +) + +T = TypeVar("T") + + +class PagingRequest(BaseModel): + """Paging parameters for paginated API requests.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int = Field(alias="pageNumber", default=1) + page_size: int = Field(alias="pageSize", default=50) + + +class ApiResponse(BaseModel, Generic[T]): + """Generic wrapper for all API responses with status and error handling.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + status_code: int + is_successful: bool = True + message: Optional[str] = None + data: Optional[T] = None + raw_response: Optional[Response] = None + + @model_validator(mode="before") + @classmethod + def set_is_successful(cls, data): + """Automatically infer is_successful from status_code if not provided.""" + if isinstance(data, dict) and "is_successful" not in data and "status_code" in data: + data["is_successful"] = int(data["status_code"]) < 400 + return data + + def raise_for_status(self): + """Raise the appropriate exception if the request was not successful.""" + if not self.is_successful: + if self.status_code in (401, 403): + raise OnspringAuthenticationError(self.message or "Authentication failed") + if self.status_code == 404: + raise OnspringNotFoundError(self.message or "Resource not found") + if self.status_code == 429: + raise OnspringRateLimitError(self.message or "Rate limit exceeded") + raise OnspringError(self.message or f"Request failed with status {self.status_code}") diff --git a/src/onspring_api_sdk/models/field.py b/src/onspring_api_sdk/models/field.py new file mode 100644 index 0000000..91b9280 --- /dev/null +++ b/src/onspring_api_sdk/models/field.py @@ -0,0 +1,63 @@ +"""Pydantic models for Onspring field API responses.""" + +import uuid +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ListValue(BaseModel): + """A selectable value within a list field definition.""" + + model_config = ConfigDict(populate_by_name=True) + + id: uuid.UUID + name: str + sort_order: int = Field(alias="sortOrder") + numeric_value: Optional[int] = Field(default=None, alias="numericValue") + color: Optional[str] = None + + +class OnspringField(BaseModel): + """Represents an Onspring field definition.""" + + model_config = ConfigDict(populate_by_name=True) + + id: int + app_id: int = Field(alias="appId") + name: str + type: str + status: str + is_required: bool = Field(alias="isRequired") + is_unique: bool = Field(alias="isUnique") + list_id: Optional[int] = Field(default=None, alias="listId") + values: Optional[list[ListValue]] = None + multiplicity: Optional[str] = None + output_type: Optional[str] = Field(default=None, alias="outputType") + + +class GetFieldByIdResponse(BaseModel): + """Response containing a single field.""" + + field: OnspringField + + +class GetFieldsByIdsResponse(BaseModel): + """Response containing fields for requested IDs.""" + + model_config = ConfigDict(populate_by_name=True) + + count: int + fields: list[OnspringField] = Field(alias="items") + + +class GetFieldsByAppIdResponse(BaseModel): + """Paginated response containing fields for an app.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int = Field(alias="pageNumber") + page_size: int = Field(alias="pageSize") + total_pages: int = Field(alias="totalPages") + total_records: int = Field(alias="totalRecords") + fields: list[OnspringField] = Field(alias="items") diff --git a/src/onspring_api_sdk/models/file.py b/src/onspring_api_sdk/models/file.py new file mode 100644 index 0000000..c287034 --- /dev/null +++ b/src/onspring_api_sdk/models/file.py @@ -0,0 +1,65 @@ +"""Pydantic models for Onspring file API requests and responses.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class File(BaseModel): + """Represents a file with its metadata and binary content.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str + content_type: str = Field(alias="contentType") + content_length: int = Field(alias="contentLength") + content: bytes + + +class FileInfo(BaseModel): + """Metadata about a file stored in Onspring.""" + + model_config = ConfigDict(populate_by_name=True) + + type: str + content_type: str = Field(alias="contentType") + name: str + created_date: Optional[datetime] = Field(default=None, alias="createdDate") + modified_date: Optional[datetime] = Field(default=None, alias="modifiedDate") + owner: str + file_href: str = Field(alias="fileHref") + + +class GetFileInfoByIdResponse(BaseModel): + """Response containing file metadata.""" + + model_config = ConfigDict(populate_by_name=True) + + file_info: FileInfo = Field(alias="fileInfo") + + +class GetFileByIdResponse(BaseModel): + """Response containing a file with binary content.""" + + file: File + + +class SaveFileRequest(BaseModel): + """Request payload for uploading a file to a record field.""" + + model_config = ConfigDict(populate_by_name=True) + + record_id: int = Field(alias="recordId") + field_id: int = Field(alias="fieldId") + file_name: str = Field(alias="fileName") + file_path: str = Field(alias="filePath") + content_type: str = Field(alias="contentType") + notes: Optional[str] = None + modified_date: Optional[datetime] = Field(default=None, alias="modifiedDate") + + +class SaveFileResponse(BaseModel): + """Response containing the ID of a saved file.""" + + id: int diff --git a/src/onspring_api_sdk/models/list.py b/src/onspring_api_sdk/models/list.py new file mode 100644 index 0000000..e2f99dd --- /dev/null +++ b/src/onspring_api_sdk/models/list.py @@ -0,0 +1,24 @@ +"""Pydantic models for Onspring list item API requests and responses.""" + +import uuid +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ListItemRequest(BaseModel): + """Request payload for adding or updating a list item.""" + + model_config = ConfigDict(populate_by_name=True) + + list_id: int = Field(alias="listId") + name: str + id: Optional[uuid.UUID] = None + numeric_value: Optional[int] = Field(default=None, alias="numericValue") + color: Optional[str] = None + + +class AddOrUpdateListItemResponse(BaseModel): + """Response containing the ID of an added or updated list item.""" + + id: uuid.UUID diff --git a/src/onspring_api_sdk/models/record.py b/src/onspring_api_sdk/models/record.py new file mode 100644 index 0000000..943d741 --- /dev/null +++ b/src/onspring_api_sdk/models/record.py @@ -0,0 +1,252 @@ +"""Pydantic models for Onspring record data, requests, and responses.""" + +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, Tag + +from onspring_api_sdk.enums import DataFormat + + +class RecordFieldValue(BaseModel): + """Base model for all record field value types.""" + + model_config = ConfigDict(populate_by_name=True) + + field_id: int = Field(alias="fieldId") + value: object = None + type: str + + +class StringFieldValue(RecordFieldValue): + """Field value containing a string.""" + + type: Literal["String"] = "String" + value: Optional[str] = None + + +class IntegerFieldValue(RecordFieldValue): + """Field value containing an integer.""" + + type: Literal["Integer"] = "Integer" + value: Optional[int] = None + + +class DecimalFieldValue(RecordFieldValue): + """Field value containing a decimal number.""" + + type: Literal["Decimal"] = "Decimal" + value: Optional[Decimal] = None + + +class DateFieldValue(RecordFieldValue): + """Field value containing a date.""" + + type: Literal["Date"] = "Date" + value: Optional[datetime] = None + + +class GuidFieldValue(RecordFieldValue): + """Field value containing a GUID.""" + + type: Literal["Guid"] = "Guid" + value: Optional[uuid.UUID] = None + + +class TimeSpanData(BaseModel): + """Time span configuration with increment, recurrence, and end conditions.""" + + model_config = ConfigDict(populate_by_name=True) + + quantity: Optional[Decimal] = None + increment: Optional[str] = None + recurrence: Optional[str] = None + end_by_date: Optional[datetime] = Field(default=None, alias="endByDate") + end_after_occurrences: Optional[int] = Field(default=None, alias="endAfterOccurrences") + + +class TimeSpanValue(RecordFieldValue): + """Field value containing a time span.""" + + type: Literal["TimeSpan"] = "TimeSpan" + value: Optional[TimeSpanData] = None + + +class StringListValue(RecordFieldValue): + """Field value containing a list of strings.""" + + type: Literal["StringList"] = "StringList" + value: Optional[list[str]] = None + + +class IntegerListValue(RecordFieldValue): + """Field value containing a list of integers.""" + + type: Literal["IntegerList"] = "IntegerList" + value: Optional[list[int]] = None + + +class GuidListValue(RecordFieldValue): + """Field value containing a list of GUIDs.""" + + type: Literal["GuidList"] = "GuidList" + value: Optional[list[uuid.UUID]] = None + + +class Attachment(BaseModel): + """An attachment associated with a record field.""" + + model_config = ConfigDict(populate_by_name=True) + + file_id: int = Field(alias="fileId") + file_name: str = Field(alias="fileName") + notes: Optional[str] = None + storage_location: str = Field(alias="storageLocation") + + +class AttachmentListValue(RecordFieldValue): + """Field value containing a list of attachments.""" + + type: Literal["AttachmentList"] = "AttachmentList" + value: Optional[list[Attachment]] = None + + +class ScoringGroup(BaseModel): + """A scoring group with a list value reference, name, and scores.""" + + model_config = ConfigDict(populate_by_name=True) + + list_value_id: Optional[uuid.UUID] = Field(default=None, alias="listValueId") + name: Optional[str] = None + score: Optional[Decimal] = None + maximum_score: Optional[Decimal] = Field(default=None, alias="maximumScore") + delegate_type: Optional[str] = Field(default=None, alias="delegateType") + + +class ScoringGroupListValue(RecordFieldValue): + """Field value containing a list of scoring groups.""" + + type: Literal["ScoringGroupList"] = "ScoringGroupList" + value: Optional[list[ScoringGroup]] = None + + +class FileListValue(RecordFieldValue): + """Field value containing a list of file IDs.""" + + type: Literal["FileList"] = "FileList" + value: Optional[list[int]] = None + + +FieldValue = Annotated[ + Union[ + Annotated[StringFieldValue, Tag("String")], + Annotated[IntegerFieldValue, Tag("Integer")], + Annotated[DecimalFieldValue, Tag("Decimal")], + Annotated[DateFieldValue, Tag("Date")], + Annotated[GuidFieldValue, Tag("Guid")], + Annotated[TimeSpanValue, Tag("TimeSpan")], + Annotated[StringListValue, Tag("StringList")], + Annotated[IntegerListValue, Tag("IntegerList")], + Annotated[GuidListValue, Tag("GuidList")], + Annotated[AttachmentListValue, Tag("AttachmentList")], + Annotated[ScoringGroupListValue, Tag("ScoringGroupList")], + Annotated[FileListValue, Tag("FileList")], + ], + Field(discriminator="type"), +] + + +class Record(BaseModel): + """A record containing typed field values for a specific app.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + record_id: Optional[int] = Field(default=None, alias="recordId") + fields: list[FieldValue] = Field(default_factory=list, alias="fieldData") + + +class GetRecordsByAppRequest(BaseModel): + """Request parameters for fetching records by app ID.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + field_ids: list[int] = Field(default_factory=list, alias="fieldIds") + data_format: str = Field(default=DataFormat.Raw.name, alias="dataFormat") + page_number: int = Field(default=1, alias="pageNumber") + page_size: int = Field(default=50, alias="pageSize") + + +class QueryRecordsRequest(BaseModel): + """Request parameters for querying records with a filter.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + filter: str + field_ids: list[int] = Field(default_factory=list, alias="fieldIds") + data_format: str = Field(default=DataFormat.Raw.name, alias="dataFormat") + page_number: int = Field(default=1, alias="pageNumber") + page_size: int = Field(default=50, alias="pageSize") + + +class GetRecordByIdRequest(BaseModel): + """Request parameters for fetching a record by ID.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + record_id: int = Field(alias="recordId") + field_ids: list[int] = Field(default_factory=list, alias="fieldIds") + data_format: str = Field(default=DataFormat.Raw.name, alias="dataFormat") + + +class GetBatchRecordsRequest(BaseModel): + """Request parameters for fetching multiple records by IDs.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + record_ids: list[int] = Field(alias="recordIds") + field_ids: list[int] = Field(default_factory=list, alias="fieldIds") + data_format: str = Field(default=DataFormat.Raw.name, alias="dataFormat") + + +class DeleteBatchRecordsRequest(BaseModel): + """Request payload for deleting multiple records.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + record_ids: list[int] = Field(alias="recordIds") + + +class GetRecordsResponse(BaseModel): + """Paginated response containing records.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int = Field(alias="pageNumber") + page_size: int = Field(alias="pageSize") + total_pages: int = Field(alias="totalPages") + total_records: int = Field(alias="totalRecords") + records: list[Record] = Field(alias="items") + + +class GetBatchRecordsResponse(BaseModel): + """Response containing records for requested IDs.""" + + model_config = ConfigDict(populate_by_name=True) + + count: int + records: list[Record] = Field(alias="items") + + +class AddOrUpdateRecordResponse(BaseModel): + """Response containing the ID and warnings from an add/update operation.""" + + id: int + warnings: list[str] = Field(default_factory=list) diff --git a/src/onspring_api_sdk/models/report.py b/src/onspring_api_sdk/models/report.py new file mode 100644 index 0000000..87640f5 --- /dev/null +++ b/src/onspring_api_sdk/models/report.py @@ -0,0 +1,56 @@ +"""Pydantic models for Onspring report API requests and responses.""" + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from onspring_api_sdk.enums import DataFormat, ReportDataType + + +class Row(BaseModel): + """A single row of report data.""" + + model_config = ConfigDict(populate_by_name=True) + + record_id: Optional[int] = Field(alias="recordId", default=None) + cells: list[Any] + + +class Report(BaseModel): + """Represents an Onspring report.""" + + model_config = ConfigDict(populate_by_name=True) + + app_id: int = Field(alias="appId") + id: int + name: str + description: Optional[str] = None + + +class GetReportByIdRequest(BaseModel): + """Request parameters for fetching a report by ID.""" + + model_config = ConfigDict(populate_by_name=True) + + report_id: int = Field(alias="reportId") + api_data_format: str = Field(default=DataFormat.Raw.name, alias="apiDataFormat") + data_type: str = Field(default=ReportDataType.ReportData.name, alias="dataType") + + +class GetReportByIdResponse(BaseModel): + """Response containing report columns and rows.""" + + columns: list[str] + rows: list[Row] + + +class GetReportsByAppIdResponse(BaseModel): + """Paginated response containing reports for an app.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int = Field(alias="pageNumber") + page_size: int = Field(alias="pageSize") + total_pages: int = Field(alias="totalPages") + total_records: int = Field(alias="totalRecords") + reports: list[Report] = Field(alias="items") diff --git a/src/OnspringApiSdk/__init__.py b/tests/__init__.py similarity index 100% rename from src/OnspringApiSdk/__init__.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4793df1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,176 @@ +from pathlib import Path + +import pytest + +from onspring_api_sdk import AsyncOnspringClient, OnspringClient + +TEST_URL = "https://test.com" +TEST_API_KEY = "apiKey" + + +@pytest.fixture +def client(): + return OnspringClient(TEST_URL, TEST_API_KEY) + + +@pytest.fixture +def async_client(): + return AsyncOnspringClient(TEST_URL, TEST_API_KEY) + + +MOCK_APP = { + "href": "https://api.onspring.com/Apps/id/1", + "id": 1, + "name": "Test App", +} + +MOCK_APPS_RESPONSE = { + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 2, + "items": [ + MOCK_APP, + {**MOCK_APP, "id": 2, "name": "App 2"}, + ], +} + +MOCK_APPS_BATCH_RESPONSE = { + "count": 2, + "items": [MOCK_APP, {**MOCK_APP, "id": 2, "name": "App 2"}], +} + + +MOCK_FIELD = { + "id": 1, + "appId": 10, + "name": "Test Field", + "type": "Text", + "status": "Enabled", + "isRequired": True, + "isUnique": False, +} + +MOCK_LIST_FIELD = { + "id": 2, + "appId": 10, + "name": "List Field", + "type": "List", + "status": "Enabled", + "isRequired": False, + "isUnique": False, + "multiplicity": "SingleSelect", + "listId": 100, + "values": [ + { + "id": "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84", + "name": "list_value_1", + "sortOrder": 1, + "numericValue": 1, + "color": "#008e8e", + }, + ], +} + +MOCK_FIELDS_RESPONSE = { + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 2, + "items": [MOCK_FIELD, MOCK_LIST_FIELD], +} + +MOCK_FIELDS_BATCH_RESPONSE = { + "count": 2, + "items": [MOCK_FIELD, MOCK_LIST_FIELD], +} + + +MOCK_FILE_INFO = { + "type": "Attachment", + "contentType": "text/plain", + "name": "test.txt", + "createdDate": "2024-01-01T00:00:00", + "modifiedDate": "2024-01-01T00:00:00", + "owner": "Test User", + "notes": "Test note", + "fileHref": "https://api.onspring.com/Files/...", +} + +MOCK_SAVE_FILE_RESPONSE = {"id": 1} + + +MOCK_LIST_ITEM_RESPONSE = {"id": "00000000-0000-0000-0000-000000000000"} + + +MOCK_RECORD = { + "appId": 100, + "recordId": 1, + "fieldData": [ + {"fieldId": 1, "value": "Test Value", "type": "String"}, + {"fieldId": 2, "value": 42, "type": "Integer"}, + ], +} + +MOCK_RECORDS_RESPONSE = { + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 1, + "items": [MOCK_RECORD], +} + +MOCK_RECORDS_BATCH_RESPONSE = { + "count": 1, + "items": [MOCK_RECORD], +} + +MOCK_SAVE_RECORD_RESPONSE = { + "id": 1, + "warnings": [], +} + + +MOCK_REPORT_RESPONSE = { + "columns": ["col1", "col2"], + "rows": [ + {"recordId": 1, "cells": ["a", "b"]}, + ], +} + +MOCK_REPORT_BY_APP = { + "appId": 10, + "id": 53, + "name": "Test Report", + "description": "A test report", +} + +MOCK_REPORTS_BY_APP_RESPONSE = { + "pageNumber": 1, + "pageSize": 50, + "totalPages": 1, + "totalRecords": 1, + "items": [MOCK_REPORT_BY_APP], +} + + +MOCK_MESSAGE_RESPONSE = {"message": "An error occurred"} + + +TEMP_DIR = Path(__file__).parent / "_temp" + + +@pytest.fixture(autouse=True) +def manage_temp_dir(): + TEMP_DIR.mkdir(exist_ok=True) + yield + if TEMP_DIR.exists(): + for f in TEMP_DIR.iterdir(): + f.unlink() + TEMP_DIR.rmdir() + + +def create_temp_file(name: str = "test.txt", content: bytes = b"Hello World!") -> Path: + path = TEMP_DIR / name + path.write_bytes(content) + return path diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..6c7468c --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,188 @@ +import os +from pathlib import Path + +import pytest +from dotenv import load_dotenv + +from onspring_api_sdk import AsyncOnspringClient, OnspringClient + +load_dotenv() + + +def _required_env(key: str) -> str: + value = os.environ.get(key) + if value is None: + pytest.fail(f"{key} is not defined") + return value + + +def _optional_env(key: str) -> str | None: + return os.environ.get(key) + + +def pytest_configure(config): + config.addinivalue_line("markers", "integration: marks tests as integration tests (real API calls)") + + +@pytest.fixture(scope="session") +def base_url(): + return _required_env("API_BASE_URL") + + +@pytest.fixture(scope="session") +def api_key(): + return _required_env("SANDBOX_API_KEY") + + +@pytest.fixture(scope="session") +def test_app_id(): + return int(_required_env("TEST_APP_ID")) + + +@pytest.fixture(scope="session") +def test_survey_id(): + return int(_required_env("TEST_SURVEY_ID")) + + +@pytest.fixture(scope="session") +def test_app_id_no_access(): + return int(_required_env("TEST_APP_ID_NO_ACCESS")) + + +@pytest.fixture(scope="session") +def test_app_ids(): + raw = _required_env("TEST_APP_IDS") + return [int(x) for x in raw.split(",")] + + +@pytest.fixture(scope="session") +def test_app_ids_no_access(): + raw = _required_env("TEST_APP_IDS_NO_ACCESS") + return [int(x) for x in raw.split(",")] + + +@pytest.fixture(scope="session") +def test_field_id(): + return int(_required_env("TEST_FIELD_ID")) + + +@pytest.fixture(scope="session") +def test_field_id_no_access(): + return int(_required_env("TEST_FIELD_ID_NO_ACCESS")) + + +@pytest.fixture(scope="session") +def test_field_ids(): + raw = _required_env("TEST_FIELD_IDS") + return [int(x) for x in raw.split(",")] + + +@pytest.fixture(scope="session") +def test_field_ids_no_access(): + raw = _required_env("TEST_FIELD_IDS_NO_ACCESS") + return [int(x) for x in raw.split(",")] + + +@pytest.fixture(scope="session") +def test_record(): + return int(_required_env("TEST_RECORD")) + + +@pytest.fixture(scope="session") +def test_survey_record_id(): + return int(_required_env("TEST_SURVEY_RECORD_ID")) + + +@pytest.fixture(scope="session") +def test_text_field(): + return int(_required_env("TEST_TEXT_FIELD")) + + +@pytest.fixture(scope="session") +def test_attachment_field(): + return int(_required_env("TEST_ATTACHMENT_FIELD")) + + +@pytest.fixture(scope="session") +def test_attachment_field_no_access_field(): + return int(_required_env("TEST_ATTACHMENT_FIELD_NO_ACCESS_FIELD")) + + +@pytest.fixture(scope="session") +def test_attachment_field_no_access_app(): + return int(_required_env("TEST_ATTACHMENT_FIELD_NO_ACCESS_APP")) + + +@pytest.fixture(scope="session") +def test_attachment(): + return int(_required_env("TEST_ATTACHMENT")) + + +@pytest.fixture(scope="session") +def test_image_field(): + return int(_required_env("TEST_IMAGE_FIELD")) + + +@pytest.fixture(scope="session") +def test_image(): + return int(_required_env("TEST_IMAGE")) + + +@pytest.fixture(scope="session") +def test_list_field(): + return int(_required_env("TEST_LIST_FIELD")) + + +@pytest.fixture(scope="session") +def test_list_field_no_access(): + return int(_required_env("TEST_LIST_FIELD_NO_ACCESS")) + + +@pytest.fixture(scope="session") +def test_list_id(): + return int(_required_env("TEST_LIST_ID")) + + +@pytest.fixture(scope="session") +def test_list_id_no_access(): + return int(_required_env("TEST_LIST_ID_NO_ACCESS")) + + +@pytest.fixture(scope="session") +def test_list_item_id_no_access(): + return _required_env("TEST_LIST_ITEM_ID_NO_ACCESS") + + +@pytest.fixture(scope="session") +def test_report(): + return int(_required_env("TEST_REPORT")) + + +@pytest.fixture(scope="session") +def test_report_no_access(): + return int(_required_env("TEST_REPORT_NO_ACCESS")) + + +@pytest.fixture(scope="session") +def test_report_with_chart_data(): + return int(_required_env("TEST_REPORT_WITH_CHART_DATA")) + + +@pytest.fixture(scope="session") +def test_survey_auto_number_field(): + return _optional_env("TEST_SURVEY_AUTO_NUMBER_FIELD") + + +@pytest.fixture(scope="session") +def testdata_dir(): + return Path(__file__).parent / "testdata" + + +@pytest.fixture +def client(base_url, api_key): + return OnspringClient(base_url, api_key) + + +@pytest.fixture +def async_client(base_url, api_key): + return AsyncOnspringClient(base_url, api_key) diff --git a/tests/integration/test_apps.py b/tests/integration/test_apps.py new file mode 100644 index 0000000..b4802d5 --- /dev/null +++ b/tests/integration/test_apps.py @@ -0,0 +1,243 @@ +import pytest + +from onspring_api_sdk.models import PagingRequest + +pytestmark = pytest.mark.integration + + +class TestGetApps: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client): + response = client.get_apps() + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.page_number is not None + assert response.data.page_size is not None + assert response.data.total_pages is not None + assert response.data.total_records is not None + assert response.data.apps is not None + assert len(response.data.apps) > 0 + + for app in response.data.apps: + assert app.id is not None + assert app.name is not None + assert app.href is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client): + response = await async_client.get_apps() + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.apps) > 0 + + @pytest.mark.flaky(reruns=3) + def test_with_paging_request(self, client): + response = client.get_apps(paging_request=PagingRequest(page_number=1, page_size=1)) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.apps) <= 1 + + @pytest.mark.flaky(reruns=3) + async def test_with_paging_request_async(self, async_client): + response = await async_client.get_apps(paging_request=PagingRequest(page_number=1, page_size=1)) + + assert response.status_code == 200 + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.apps) <= 1 + + @pytest.mark.flaky(reruns=3) + def test_invalid_page_size_returns_400(self, client): + response = client.get_apps(paging_request=PagingRequest(page_number=1, page_size=-1)) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_page_size_returns_400_async(self, async_client): + response = await async_client.get_apps(paging_request=PagingRequest(page_number=1, page_size=-1)) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_apps() + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_apps() + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + +class TestGetAppById: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_app_id): + response = client.get_app_by_id(app_id=test_app_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.app.id == test_app_id + assert response.data.app.name is not None + assert response.data.app.href is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_app_id): + response = await async_client.get_app_by_id(app_id=test_app_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.app.id == test_app_id + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_app_by_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_app_by_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + response = client.get_app_by_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access): + response = await async_client.get_app_by_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_not_found_returns_404(self, client): + response = client.get_app_by_id(app_id=0) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_not_found_returns_404_async(self, async_client): + response = await async_client.get_app_by_id(app_id=0) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestGetAppsByIds: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_app_ids): + response = client.get_apps_by_ids(app_ids=test_app_ids) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.apps is not None + assert len(response.data.apps) == len(test_app_ids) + + for app in response.data.apps: + assert app.id is not None + assert app.name is not None + assert app.href is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_app_ids): + response = await async_client.get_apps_by_ids(app_ids=test_app_ids) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.apps) == len(test_app_ids) + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_apps_by_ids(app_ids=[1]) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_apps_by_ids(app_ids=[1]) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_ids_no_access): + response = client.get_apps_by_ids(app_ids=test_app_ids_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_ids_no_access): + response = await async_client.get_apps_by_ids(app_ids=test_app_ids_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None diff --git a/tests/integration/test_fields.py b/tests/integration/test_fields.py new file mode 100644 index 0000000..d7a6fb2 --- /dev/null +++ b/tests/integration/test_fields.py @@ -0,0 +1,284 @@ +import pytest + +from onspring_api_sdk.models import PagingRequest + +pytestmark = pytest.mark.integration + + +class TestGetFieldById: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_field_id): + response = client.get_field_by_id(field_id=test_field_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.field.id == test_field_id + assert response.data.field.name is not None + assert response.data.field.app_id is not None + assert response.data.field.type is not None + assert response.data.field.status is not None + assert response.data.field.is_required is not None + assert response.data.field.is_unique is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_field_id): + response = await async_client.get_field_by_id(field_id=test_field_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.field.id == test_field_id + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_field_by_id(field_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_field_by_id(field_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_field_id_no_access): + response = client.get_field_by_id(field_id=test_field_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_field_id_no_access): + response = await async_client.get_field_by_id(field_id=test_field_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_not_found_returns_404(self, client): + response = client.get_field_by_id(field_id=0) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_not_found_returns_404_async(self, async_client): + response = await async_client.get_field_by_id(field_id=0) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestGetFieldsByAppId: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id): + response = client.get_fields_by_app_id(app_id=test_survey_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.page_number is not None + assert response.data.page_size is not None + assert response.data.total_pages is not None + assert response.data.total_records is not None + assert response.data.fields is not None + assert len(response.data.fields) > 0 + + for field in response.data.fields: + assert field.id is not None + assert field.name is not None + assert field.app_id is not None + assert field.type is not None + assert field.status is not None + assert field.is_required is not None + assert field.is_unique is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id): + response = await async_client.get_fields_by_app_id(app_id=test_survey_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.fields) > 0 + + @pytest.mark.flaky(reruns=3) + def test_with_paging_request(self, client, test_survey_id): + response = client.get_fields_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=1), + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.fields) <= 1 + + @pytest.mark.flaky(reruns=3) + async def test_with_paging_request_async(self, async_client, test_survey_id): + response = await async_client.get_fields_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=1), + ) + + assert response.status_code == 200 + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.fields) <= 1 + + @pytest.mark.flaky(reruns=3) + def test_invalid_page_size_returns_400(self, client, test_survey_id): + response = client.get_fields_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=-1), + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_page_size_returns_400_async(self, async_client, test_survey_id): + response = await async_client.get_fields_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=-1), + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_fields_by_app_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_fields_by_app_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + response = client.get_fields_by_app_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access): + response = await async_client.get_fields_by_app_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + +class TestGetFieldsByIds: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_field_ids): + response = client.get_fields_by_ids(field_ids=test_field_ids) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.count == len(test_field_ids) + assert len(response.data.fields) == len(test_field_ids) + + for field in response.data.fields: + assert field.id is not None + assert field.name is not None + assert field.app_id is not None + assert field.type is not None + assert field.status is not None + assert field.is_required is not None + assert field.is_unique is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_field_ids): + response = await async_client.get_fields_by_ids(field_ids=test_field_ids) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.count == len(test_field_ids) + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_fields_by_ids(field_ids=[1, 2, 3]) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_fields_by_ids(field_ids=[1, 2, 3]) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_field_ids_no_access): + response = client.get_fields_by_ids(field_ids=test_field_ids_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_field_ids_no_access): + response = await async_client.get_fields_by_ids(field_ids=test_field_ids_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None diff --git a/tests/integration/test_files.py b/tests/integration/test_files.py new file mode 100644 index 0000000..a6f8fa6 --- /dev/null +++ b/tests/integration/test_files.py @@ -0,0 +1,679 @@ +import pytest + +from onspring_api_sdk.models import SaveFileRequest + +pytestmark = pytest.mark.integration + + +def _attachment_path(testdata_dir): + return str(testdata_dir / "test-attachment.txt") + + +def _image_path(testdata_dir): + return str(testdata_dir / "test-image.jpeg") + + +class TestGetFileInfoById: + @pytest.mark.flaky(reruns=3) + def test_attachment_field(self, client, test_record, test_attachment_field, test_attachment): + response = client.get_file_info_by_id( + record_id=test_record, + field_id=test_attachment_field, + file_id=test_attachment, + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.file_info.created_date is not None + assert response.data.file_info.content_type is not None + assert response.data.file_info.file_href is not None + assert response.data.file_info.name is not None + assert response.data.file_info.modified_date is not None + assert response.data.file_info.type is not None + assert response.data.file_info.owner is not None + + @pytest.mark.flaky(reruns=3) + async def test_attachment_field_async(self, async_client, test_record, test_attachment_field, test_attachment): + response = await async_client.get_file_info_by_id( + record_id=test_record, + field_id=test_attachment_field, + file_id=test_attachment, + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_image_field(self, client, test_record, test_image_field, test_image): + response = client.get_file_info_by_id( + record_id=test_record, + field_id=test_image_field, + file_id=test_image, + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.file_info.created_date is not None + assert response.data.file_info.content_type is not None + assert response.data.file_info.file_href is not None + assert response.data.file_info.name is not None + assert response.data.file_info.modified_date is not None + assert response.data.file_info.type is not None + assert response.data.file_info.owner is not None + + @pytest.mark.flaky(reruns=3) + async def test_image_field_async(self, async_client, test_record, test_image_field, test_image): + response = await async_client.get_file_info_by_id( + record_id=test_record, + field_id=test_image_field, + file_id=test_image, + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_non_file_field_returns_400(self, client, test_record, test_text_field, test_attachment): + response = client.get_file_info_by_id( + record_id=test_record, + field_id=test_text_field, + file_id=test_attachment, + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_non_file_field_returns_400_async(self, async_client, test_record, test_text_field, test_attachment): + response = await async_client.get_file_info_by_id( + record_id=test_record, + field_id=test_text_field, + file_id=test_attachment, + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url, test_record, test_attachment_field, test_attachment): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_file_info_by_id( + record_id=test_record, + field_id=test_attachment_field, + file_id=test_attachment, + ) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async( + self, base_url, test_record, test_attachment_field, test_attachment + ): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_file_info_by_id( + record_id=test_record, + field_id=test_attachment_field, + file_id=test_attachment, + ) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_field_access_returns_403(self, client, test_attachment_field_no_access_field): + response = client.get_file_info_by_id(record_id=1, field_id=test_attachment_field_no_access_field, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_field_access_returns_403_async(self, async_client, test_attachment_field_no_access_field): + response = await async_client.get_file_info_by_id( + record_id=1, field_id=test_attachment_field_no_access_field, file_id=1 + ) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_app_access_returns_403(self, client, test_attachment_field_no_access_app): + response = client.get_file_info_by_id(record_id=1, field_id=test_attachment_field_no_access_app, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_app_access_returns_403_async(self, async_client, test_attachment_field_no_access_app): + response = await async_client.get_file_info_by_id( + record_id=1, field_id=test_attachment_field_no_access_app, file_id=1 + ) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_field_not_found_returns_404(self, client): + response = client.get_file_info_by_id(record_id=1, field_id=0, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_field_not_found_returns_404_async(self, async_client): + response = await async_client.get_file_info_by_id(record_id=1, field_id=0, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_attachment_field): + response = client.get_file_info_by_id(record_id=0, field_id=test_attachment_field, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_record_not_found_returns_404_async(self, async_client, test_attachment_field): + response = await async_client.get_file_info_by_id(record_id=0, field_id=test_attachment_field, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestGetFileById: + @pytest.mark.flaky(reruns=3) + def test_attachment_field(self, client, test_record, test_attachment_field, test_attachment): + response = client.get_file_by_id(record_id=test_record, field_id=test_attachment_field, file_id=test_attachment) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.file.content_length is not None + assert response.data.file.content_type is not None + assert response.data.file.name is not None + assert response.data.file.content is not None + + @pytest.mark.flaky(reruns=3) + async def test_attachment_field_async(self, async_client, test_record, test_attachment_field, test_attachment): + response = await async_client.get_file_by_id( + record_id=test_record, field_id=test_attachment_field, file_id=test_attachment + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_image_field(self, client, test_record, test_image_field, test_image): + response = client.get_file_by_id(record_id=test_record, field_id=test_image_field, file_id=test_image) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.file.content_length is not None + assert response.data.file.content_type is not None + assert response.data.file.name is not None + assert response.data.file.content is not None + + @pytest.mark.flaky(reruns=3) + async def test_image_field_async(self, async_client, test_record, test_image_field, test_image): + response = await async_client.get_file_by_id( + record_id=test_record, field_id=test_image_field, file_id=test_image + ) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_non_file_field_returns_400(self, client, test_record, test_text_field, test_attachment): + response = client.get_file_by_id(record_id=test_record, field_id=test_text_field, file_id=test_attachment) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_non_file_field_returns_400_async(self, async_client, test_record, test_text_field, test_attachment): + response = await async_client.get_file_by_id( + record_id=test_record, field_id=test_text_field, file_id=test_attachment + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url, test_record, test_attachment_field, test_attachment): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_file_by_id(record_id=test_record, field_id=test_attachment_field, file_id=test_attachment) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async( + self, base_url, test_record, test_attachment_field, test_attachment + ): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_file_by_id( + record_id=test_record, field_id=test_attachment_field, file_id=test_attachment + ) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_field_access_returns_403(self, client, test_attachment_field_no_access_field): + response = client.get_file_by_id(record_id=1, field_id=test_attachment_field_no_access_field, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_field_access_returns_403_async(self, async_client, test_attachment_field_no_access_field): + response = await async_client.get_file_by_id( + record_id=1, field_id=test_attachment_field_no_access_field, file_id=1 + ) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_app_access_returns_403(self, client, test_attachment_field_no_access_app): + response = client.get_file_by_id(record_id=1, field_id=test_attachment_field_no_access_app, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_app_access_returns_403_async(self, async_client, test_attachment_field_no_access_app): + response = await async_client.get_file_by_id( + record_id=1, field_id=test_attachment_field_no_access_app, file_id=1 + ) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_field_not_found_returns_404(self, client): + response = client.get_file_by_id(record_id=1, field_id=0, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_field_not_found_returns_404_async(self, async_client): + response = await async_client.get_file_by_id(record_id=1, field_id=0, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_attachment_field): + response = client.get_file_by_id(record_id=0, field_id=test_attachment_field, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_record_not_found_returns_404_async(self, async_client, test_attachment_field): + response = await async_client.get_file_by_id(record_id=0, field_id=test_attachment_field, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestSaveFile: + _attachment_file_ids: list[int] = [] + + @pytest.fixture(autouse=True) + def cleanup_attachments(self, client, test_record, test_attachment_field): + yield + + for file_id in self._attachment_file_ids: + client.delete_file_by_id(record_id=test_record, field_id=test_attachment_field, file_id=file_id) + + self._attachment_file_ids.clear() + + @pytest.mark.flaky(reruns=3) + def test_attachment_field(self, client, test_record, test_attachment_field, testdata_dir): + request = SaveFileRequest( + record_id=test_record, + field_id=test_attachment_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + notes="integration test", + ) + + response = client.save_file(request) + + assert response.status_code == 201 + assert response.is_successful + assert response.data is not None + assert response.data.id is not None + + self._attachment_file_ids.append(response.data.id) + + @pytest.mark.flaky(reruns=3) + async def test_attachment_field_async(self, async_client, test_record, test_attachment_field, testdata_dir): + request = SaveFileRequest( + record_id=test_record, + field_id=test_attachment_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + notes="integration test", + ) + + response = await async_client.save_file(request) + + assert response.status_code == 201 + assert response.is_successful + assert response.data is not None + assert response.data.id is not None + + self._attachment_file_ids.append(response.data.id) + + @pytest.mark.flaky(reruns=3) + def test_image_field(self, client, test_record, test_image_field, testdata_dir): + request = SaveFileRequest( + record_id=test_record, + field_id=test_image_field, + file_name="test-image.jpeg", + file_path=_image_path(testdata_dir), + content_type="image/jpeg", + notes="integration test", + ) + + response = client.save_file(request) + + assert response.status_code == 201 + assert response.is_successful + assert response.data is not None + assert response.data.id is not None + + self._attachment_file_ids.append(response.data.id) + + @pytest.mark.flaky(reruns=3) + def test_non_file_field_returns_400(self, client, test_record, test_text_field, testdata_dir): + request = SaveFileRequest( + record_id=test_record, + field_id=test_text_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + notes="integration test", + ) + + response = client.save_file(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url, testdata_dir): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = SaveFileRequest( + record_id=1, + field_id=1, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + ) + + response = bad.save_file(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_field_access_returns_403(self, client, test_attachment_field_no_access_field, testdata_dir): + request = SaveFileRequest( + record_id=1, + field_id=test_attachment_field_no_access_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + ) + + response = client.save_file(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_app_access_returns_403(self, client, test_attachment_field_no_access_app, testdata_dir): + request = SaveFileRequest( + record_id=1, + field_id=test_attachment_field_no_access_app, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + ) + + response = client.save_file(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_field_not_found_returns_404(self, client, testdata_dir): + request = SaveFileRequest( + record_id=1, + field_id=0, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + ) + + response = client.save_file(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_attachment_field, testdata_dir): + request = SaveFileRequest( + record_id=0, + field_id=test_attachment_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + ) + + response = client.save_file(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + +class TestDeleteFileById: + _attachment_file_id: int | None = None + _image_file_id: int | None = None + + @pytest.fixture(autouse=True) + def setup_files(self, client, test_record, test_attachment_field, test_image_field, testdata_dir): + if self._attachment_file_id is None: + req = SaveFileRequest( + record_id=test_record, + field_id=test_attachment_field, + file_name="test-attachment.txt", + file_path=_attachment_path(testdata_dir), + content_type="text/plain", + notes="delete test", + ) + + resp = client.save_file(req) + + if resp.data is not None: + self._attachment_file_id = resp.data.id + + if self._image_file_id is None: + req = SaveFileRequest( + record_id=test_record, + field_id=test_image_field, + file_name="test-image.jpeg", + file_path=_image_path(testdata_dir), + content_type="image/jpeg", + notes="delete test", + ) + + resp = client.save_file(req) + + if resp.data is not None: + self._image_file_id = resp.data.id + + yield + + @pytest.mark.flaky(reruns=3) + def test_attachment_field(self, client, test_record, test_attachment_field): + assert self._attachment_file_id is not None + + response = client.delete_file_by_id( + record_id=test_record, + field_id=test_attachment_field, + file_id=self._attachment_file_id, + ) + + assert response.status_code == 204 + assert response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_image_field(self, client, test_record, test_image_field): + assert self._image_file_id is not None + + response = client.delete_file_by_id( + record_id=test_record, + field_id=test_image_field, + file_id=self._image_file_id, + ) + + assert response.status_code == 204 + assert response.is_successful + assert response.message is not None + + @pytest.mark.flaky(reruns=3) + def test_non_file_field_returns_400(self, client, test_record, test_text_field): + response = client.delete_file_by_id(record_id=test_record, field_id=test_text_field, file_id=1) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.delete_file_by_id(record_id=1, field_id=1, file_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_field_access_returns_403(self, client, test_attachment_field_no_access_field): + response = client.delete_file_by_id(record_id=1, field_id=test_attachment_field_no_access_field, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_app_access_returns_403(self, client, test_attachment_field_no_access_app): + response = client.delete_file_by_id(record_id=1, field_id=test_attachment_field_no_access_app, file_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_field_not_found_returns_404(self, client): + response = client.delete_file_by_id(record_id=1, field_id=0, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_attachment_field): + response = client.delete_file_by_id(record_id=0, field_id=test_attachment_field, file_id=1) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py new file mode 100644 index 0000000..b5b9571 --- /dev/null +++ b/tests/integration/test_lists.py @@ -0,0 +1,277 @@ +import uuid + +import pytest + +from onspring_api_sdk.models import ListItemRequest + +pytestmark = pytest.mark.integration + + +def _unique_name(): + return f"test_list_value_{uuid.uuid4().hex[:8]}" + + +class TestAddOrUpdateListItem: + _new_item_ids: list[uuid.UUID] = [] + + @pytest.fixture(autouse=True) + def cleanup(self, client, test_list_id): + yield + + for item_id in self._new_item_ids: + client.delete_list_item(list_id=test_list_id, item_id=str(item_id)) + + self._new_item_ids.clear() + + @pytest.mark.flaky(reruns=3) + def test_add(self, client, test_list_id): + request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + response = client.add_or_update_list_item(request) + + assert response.status_code == 201 + assert response.is_successful + assert response.message is not None + assert response.data is not None + assert response.data.id is not None + + self._new_item_ids.append(response.data.id) + + @pytest.mark.flaky(reruns=3) + async def test_add_async(self, async_client, test_list_id): + request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + response = await async_client.add_or_update_list_item(request) + + assert response.status_code == 201 + assert response.is_successful + assert response.data is not None + assert response.data.id is not None + + if response.data.id: + import os + + from onspring_api_sdk import OnspringClient + + sync = OnspringClient(os.environ["API_BASE_URL"], os.environ["SANDBOX_API_KEY"]) + sync.delete_list_item(list_id=test_list_id, item_id=str(response.data.id)) + + @pytest.mark.flaky(reruns=3) + def test_update(self, client, test_list_id): + add_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + add_response = client.add_or_update_list_item(add_request) + + assert add_response.data is not None + + item_id = add_response.data.id + self._new_item_ids.append(item_id) + + update_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + id=item_id, + numeric_value=1, + color="#000000", + ) + + update_response = client.add_or_update_list_item(update_request) + + assert update_response.status_code == 200 + assert update_response.is_successful + assert update_response.message is not None + assert update_response.data is not None + + @pytest.mark.flaky(reruns=3) + async def test_update_async(self, async_client, test_list_id): + add_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + add_response = await async_client.add_or_update_list_item(add_request) + + assert add_response.data is not None + + item_id = add_response.data.id + + import os + + from onspring_api_sdk import OnspringClient + + sync = OnspringClient(os.environ["API_BASE_URL"], os.environ["SANDBOX_API_KEY"]) + + update_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + id=item_id, + numeric_value=1, + color="#000000", + ) + + update_response = await async_client.add_or_update_list_item(update_request) + + assert update_response.status_code == 200 + assert update_response.is_successful + assert update_response.data is not None + + sync.delete_list_item(list_id=test_list_id, item_id=str(item_id)) + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = ListItemRequest(list_id=1, name="test", numeric_value=1, color="#000000") + + response = bad.add_or_update_list_item(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client): + request = ListItemRequest(list_id=1, name="test", numeric_value=1, color="#000000") + + response = client.add_or_update_list_item(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_list_not_found_returns_404(self, client): + request = ListItemRequest(list_id=0, name="test", numeric_value=1, color="#000000") + + response = client.add_or_update_list_item(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_item_not_found_returns_404(self, client, test_list_id): + request = ListItemRequest( + list_id=test_list_id, + id=uuid.UUID("3fa85f64-5717-4562-b3fc-2c963f66afa6"), + name="test", + ) + + response = client.add_or_update_list_item(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + +class TestDeleteListItem: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_list_id): + add_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + add_response = client.add_or_update_list_item(add_request) + + assert add_response.data is not None + + item_id = str(add_response.data.id) + + response = client.delete_list_item(list_id=test_list_id, item_id=item_id) + + assert response.status_code == 204 + assert response.is_successful + assert response.message is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_list_id): + import os + + from onspring_api_sdk import OnspringClient + + sync = OnspringClient(os.environ["API_BASE_URL"], os.environ["SANDBOX_API_KEY"]) + + add_request = ListItemRequest( + list_id=test_list_id, + name=_unique_name(), + numeric_value=1, + color="#000000", + ) + + add_response = sync.add_or_update_list_item(add_request) + + assert add_response.data is not None + + item_id = str(add_response.data.id) + + response = await async_client.delete_list_item(list_id=test_list_id, item_id=item_id) + + assert response.status_code == 204 + assert response.is_successful + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.delete_list_item(list_id=1, item_id="1") + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_list_id_no_access, test_list_item_id_no_access): + response = client.delete_list_item(list_id=test_list_id_no_access, item_id=test_list_item_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_list_not_found_returns_404(self, client): + response = client.delete_list_item(list_id=0, item_id="3fa85f64-5717-4562-b3fc-2c963f66afa6") + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_item_not_found_returns_404(self, client, test_list_id): + response = client.delete_list_item( + list_id=test_list_id, + item_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", + ) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None diff --git a/tests/integration/test_ping.py b/tests/integration/test_ping.py new file mode 100644 index 0000000..9c79428 --- /dev/null +++ b/tests/integration/test_ping.py @@ -0,0 +1,15 @@ +import pytest + +pytestmark = pytest.mark.integration + + +class TestCanConnect: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client): + result = client.can_connect() + assert result is True + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client): + result = await async_client.can_connect() + assert result is True diff --git a/tests/integration/test_records.py b/tests/integration/test_records.py new file mode 100644 index 0000000..16838b6 --- /dev/null +++ b/tests/integration/test_records.py @@ -0,0 +1,804 @@ +import pytest + +from onspring_api_sdk.enums import DataFormat +from onspring_api_sdk.models import ( + DeleteBatchRecordsRequest, + GetBatchRecordsRequest, + GetRecordByIdRequest, + GetRecordsByAppRequest, + QueryRecordsRequest, + Record, + StringFieldValue, +) + +pytestmark = pytest.mark.integration + + +class TestGetRecordsByAppId: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id): + request = GetRecordsByAppRequest(app_id=test_survey_id) + + response = client.get_records_by_app_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.page_number is not None + assert response.data.page_size is not None + assert response.data.total_pages is not None + assert response.data.total_records is not None + assert response.data.records is not None + + for record in response.data.records: + assert record.app_id == test_survey_id + assert record.record_id is not None + assert record.fields is not None + assert len(record.fields) > 0 + + for field in record.fields: + assert field.field_id is not None + assert field.type is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id): + request = GetRecordsByAppRequest(app_id=test_survey_id) + + response = await async_client.get_records_by_app_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.records) > 0 + + @pytest.mark.flaky(reruns=3) + def test_with_params(self, client, test_survey_id, test_text_field): + request = GetRecordsByAppRequest( + app_id=test_survey_id, + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=1, + ) + + response = client.get_records_by_app_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.records) == 1 + + @pytest.mark.flaky(reruns=3) + async def test_with_params_async(self, async_client, test_survey_id, test_text_field): + request = GetRecordsByAppRequest( + app_id=test_survey_id, + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=1, + ) + + response = await async_client.get_records_by_app_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + assert len(response.data.records) == 1 + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = GetRecordsByAppRequest(app_id=0) + + response = bad.get_records_by_app_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + request = GetRecordsByAppRequest(app_id=0) + + response = await bad.get_records_by_app_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + request = GetRecordsByAppRequest(app_id=test_app_id_no_access) + + response = client.get_records_by_app_id(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access): + request = GetRecordsByAppRequest(app_id=test_app_id_no_access) + + response = await async_client.get_records_by_app_id(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + +class TestGetRecordById: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id, test_survey_record_id): + request = GetRecordByIdRequest(app_id=test_survey_id, record_id=test_survey_record_id) + + response = client.get_record_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.app_id == test_survey_id + assert response.data.record_id == test_survey_record_id + assert response.data.fields is not None + assert len(response.data.fields) > 0 + + for field in response.data.fields: + assert field.field_id is not None + assert field.type is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id, test_survey_record_id): + request = GetRecordByIdRequest(app_id=test_survey_id, record_id=test_survey_record_id) + + response = await async_client.get_record_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.app_id == test_survey_id + assert response.data.record_id == test_survey_record_id + + @pytest.mark.flaky(reruns=3) + def test_with_params(self, client, test_survey_id, test_survey_record_id, test_text_field): + request = GetRecordByIdRequest( + app_id=test_survey_id, + record_id=test_survey_record_id, + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + ) + + response = client.get_record_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.app_id == test_survey_id + assert response.data.record_id == test_survey_record_id + assert len(response.data.fields) > 0 + + @pytest.mark.flaky(reruns=3) + async def test_with_params_async(self, async_client, test_survey_id, test_survey_record_id, test_text_field): + request = GetRecordByIdRequest( + app_id=test_survey_id, + record_id=test_survey_record_id, + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + ) + + response = await async_client.get_record_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = GetRecordByIdRequest(app_id=1, record_id=1) + + response = bad.get_record_by_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + request = GetRecordByIdRequest(app_id=1, record_id=1) + + response = await bad.get_record_by_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_survey_id): + request = GetRecordByIdRequest(app_id=test_survey_id, record_id=0) + + response = client.get_record_by_id(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_record_not_found_returns_404_async(self, async_client, test_survey_id): + request = GetRecordByIdRequest(app_id=test_survey_id, record_id=0) + + response = await async_client.get_record_by_id(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestSaveRecord: + _new_records: list[dict] = [] + + @pytest.fixture(autouse=True) + def cleanup(self, client): + yield + + for rec in self._new_records: + client.delete_record_by_id(app_id=rec["app_id"], record_id=rec["record_id"]) + + self._new_records.clear() + + @pytest.mark.flaky(reruns=3) + def test_add(self, client, test_survey_id, test_text_field): + record = Record( + app_id=test_survey_id, + fields=[StringFieldValue(field_id=test_text_field, value="Test")], + ) + + response = client.add_or_update_record(record) + + assert response.status_code == 201 + assert response.is_successful + assert response.message is not None + assert response.data is not None + assert response.data.id is not None + assert response.data.warnings is not None + + self._new_records.append({"app_id": test_survey_id, "record_id": response.data.id}) + + @pytest.mark.flaky(reruns=3) + async def test_add_async(self, async_client, test_survey_id, test_text_field): + record = Record( + app_id=test_survey_id, + fields=[StringFieldValue(field_id=test_text_field, value="Test")], + ) + + response = await async_client.add_or_update_record(record) + + assert response.status_code == 201 + assert response.is_successful + assert response.data is not None + assert response.data.id is not None + self._new_records.append({"app_id": test_survey_id, "record_id": response.data.id}) + + if response.data.id: + import os + + from onspring_api_sdk import OnspringClient + + sync = OnspringClient(os.environ["API_BASE_URL"], os.environ["SANDBOX_API_KEY"]) + sync.delete_record_by_id(app_id=test_survey_id, record_id=response.data.id) + + @pytest.mark.flaky(reruns=3) + def test_update(self, client, test_survey_id, test_text_field): + new_record = Record( + app_id=test_survey_id, + fields=[StringFieldValue(field_id=test_text_field, value="Test")], + ) + + new_response = client.add_or_update_record(new_record) + + assert new_response.data is not None + + new_id = new_response.data.id + + self._new_records.append({"app_id": test_survey_id, "record_id": new_id}) + + update_record = Record( + app_id=test_survey_id, + record_id=new_id, + fields=[StringFieldValue(field_id=test_text_field, value="updated")], + ) + + response = client.add_or_update_record(update_record) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.id == new_id + assert response.data.warnings is not None + + @pytest.mark.flaky(reruns=3) + def test_empty_fields_returns_400(self, client, test_survey_id): + record = Record(app_id=test_survey_id, fields=[]) + + response = client.add_or_update_record(record) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + record = Record(app_id=0, fields=[]) + + response = bad.add_or_update_record(record) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + record = Record(app_id=test_app_id_no_access, fields=[]) + + response = client.add_or_update_record(record) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_survey_id): + record = Record(app_id=test_survey_id, record_id=0, fields=[]) + + response = client.add_or_update_record(record) + + assert response.status_code == 404 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + +class TestDeleteRecordById: + def _create_temp_record(self, client, test_survey_id, test_text_field): + record = Record( + app_id=test_survey_id, + fields=[StringFieldValue(field_id=test_text_field, value="to_delete")], + ) + + response = client.add_or_update_record(record) + + assert response.data is not None + return response.data.id + + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id, test_text_field): + record_id = self._create_temp_record(client, test_survey_id, test_text_field) + + response = client.delete_record_by_id(app_id=test_survey_id, record_id=record_id) + + assert response.status_code == 204 + assert response.is_successful + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id, test_text_field): + import os + + from onspring_api_sdk import OnspringClient + + sync = OnspringClient(os.environ["API_BASE_URL"], os.environ["SANDBOX_API_KEY"]) + + record_id = self._create_temp_record(sync, test_survey_id, test_text_field) + + response = await async_client.delete_record_by_id(app_id=test_survey_id, record_id=record_id) + + assert response.status_code == 204 + assert response.is_successful + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.delete_record_by_id(app_id=1, record_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + response = client.delete_record_by_id(app_id=test_app_id_no_access, record_id=1) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_record_not_found_returns_404(self, client, test_app_id): + response = client.delete_record_by_id(app_id=test_app_id, record_id=0) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestDeleteRecordsByIds: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id, test_text_field): + r1 = client.add_or_update_record( + Record(app_id=test_survey_id, fields=[StringFieldValue(field_id=test_text_field, value="del1")]) + ) + + r2 = client.add_or_update_record( + Record(app_id=test_survey_id, fields=[StringFieldValue(field_id=test_text_field, value="del2")]) + ) + + assert r1.data is not None and r2.data is not None + + request = DeleteBatchRecordsRequest(app_id=test_survey_id, record_ids=[r1.data.id, r2.data.id]) + + response = client.delete_records_by_ids(request) + + assert response.status_code == 204 + assert response.is_successful + + @pytest.mark.flaky(reruns=3) + def test_empty_ids_returns_400(self, client, test_survey_id): + request = DeleteBatchRecordsRequest(app_id=test_survey_id, record_ids=[]) + + response = client.delete_records_by_ids(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = DeleteBatchRecordsRequest(app_id=1, record_ids=[1]) + + response = bad.delete_records_by_ids(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + request = DeleteBatchRecordsRequest(app_id=test_app_id_no_access, record_ids=[1]) + + response = client.delete_records_by_ids(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + +class TestGetRecordsByIds: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id, test_survey_record_id): + request = GetBatchRecordsRequest(app_id=test_survey_id, record_ids=[test_survey_record_id]) + + response = client.get_records_by_ids(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.count is not None + assert len(response.data.records) > 0 + + for record in response.data.records: + assert record.app_id == test_survey_id + assert record.record_id is not None + assert record.fields is not None + assert len(record.fields) > 0 + + for field in record.fields: + assert field.field_id is not None + assert field.type is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id, test_survey_record_id): + request = GetBatchRecordsRequest(app_id=test_survey_id, record_ids=[test_survey_record_id]) + + response = await async_client.get_records_by_ids(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.records) > 0 + + @pytest.mark.flaky(reruns=3) + def test_with_params(self, client, test_survey_id, test_survey_record_id, test_text_field): + request = GetBatchRecordsRequest( + app_id=test_survey_id, + record_ids=[test_survey_record_id], + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + ) + + response = client.get_records_by_ids(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.records) > 0 + + @pytest.mark.flaky(reruns=3) + async def test_with_params_async(self, async_client, test_survey_id, test_survey_record_id, test_text_field): + request = GetBatchRecordsRequest( + app_id=test_survey_id, + record_ids=[test_survey_record_id], + field_ids=[test_text_field], + data_format=DataFormat.Formatted.name, + ) + + response = await async_client.get_records_by_ids(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_too_many_ids_returns_400(self, client): + record_ids = list(range(1, 102)) + request = GetBatchRecordsRequest(app_id=1, record_ids=record_ids) + + response = client.get_records_by_ids(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_too_many_ids_returns_400_async(self, async_client): + record_ids = list(range(1, 102)) + request = GetBatchRecordsRequest(app_id=1, record_ids=record_ids) + + response = await async_client.get_records_by_ids(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = GetBatchRecordsRequest(app_id=1, record_ids=[1]) + + response = bad.get_records_by_ids(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + request = GetBatchRecordsRequest(app_id=1, record_ids=[1]) + + response = await bad.get_records_by_ids(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + request = GetBatchRecordsRequest(app_id=test_app_id_no_access, record_ids=[1]) + + response = client.get_records_by_ids(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access): + request = GetBatchRecordsRequest(app_id=test_app_id_no_access, record_ids=[1]) + + response = await async_client.get_records_by_ids(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + +class TestQueryRecords: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest(app_id=test_survey_id, filter=filter_str) + + response = client.query_records(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.page_number is not None + assert response.data.page_size is not None + assert response.data.total_pages is not None + assert response.data.total_records is not None + + for record in response.data.records: + assert record.app_id == test_survey_id + assert record.record_id is not None + assert record.fields is not None + + if record.fields: + for field in record.fields: + assert field.field_id is not None + assert field.type is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest(app_id=test_survey_id, filter=filter_str) + + response = await async_client.query_records(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.total_records is not None + + @pytest.mark.flaky(reruns=3) + def test_with_params(self, client, test_survey_id, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest( + app_id=test_survey_id, + filter=filter_str, + field_ids=[test_survey_auto_number_field], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=1, + ) + + response = client.query_records(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + + @pytest.mark.flaky(reruns=3) + async def test_with_params_async(self, async_client, test_survey_id, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest( + app_id=test_survey_id, + filter=filter_str, + field_ids=[test_survey_auto_number_field], + data_format=DataFormat.Formatted.name, + page_number=1, + page_size=1, + ) + + response = await async_client.query_records(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.page_number == 1 + assert response.data.page_size == 1 + + @pytest.mark.flaky(reruns=3) + def test_invalid_page_size_returns_400(self, client, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest( + app_id=1, + filter=filter_str, + page_size=-1, + ) + + response = client.query_records(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_page_size_returns_400_async(self, async_client, test_survey_auto_number_field): + filter_str = f"{test_survey_auto_number_field} gt 0" + request = QueryRecordsRequest( + app_id=1, + filter=filter_str, + page_size=-1, + ) + + response = await async_client.query_records(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = QueryRecordsRequest(app_id=1, filter="") + + response = bad.query_records(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + request = QueryRecordsRequest(app_id=1, filter="") + + response = await bad.query_records(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access, test_text_field): + filter_str = f"{test_text_field} gt ''" + request = QueryRecordsRequest(app_id=test_app_id_no_access, filter=filter_str) + + response = client.query_records(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access, test_text_field): + filter_str = f"{test_text_field} gt ''" + request = QueryRecordsRequest(app_id=test_app_id_no_access, filter=filter_str) + + response = await async_client.query_records(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None diff --git a/tests/integration/test_reports.py b/tests/integration/test_reports.py new file mode 100644 index 0000000..3b131e3 --- /dev/null +++ b/tests/integration/test_reports.py @@ -0,0 +1,278 @@ +import pytest + +from onspring_api_sdk.enums import ReportDataType +from onspring_api_sdk.models import GetReportByIdRequest, PagingRequest + +pytestmark = pytest.mark.integration + + +class TestGetReportById: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_report): + request = GetReportByIdRequest(report_id=test_report) + + response = client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.columns is not None + assert len(response.data.columns) > 0 + assert response.data.rows is not None + assert len(response.data.rows) > 0 + + for row in response.data.rows: + assert row.record_id is not None + assert row.cells is not None + assert len(row.cells) > 0 + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_report): + request = GetReportByIdRequest(report_id=test_report) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.rows) > 0 + + @pytest.mark.flaky(reruns=3) + def test_with_chart_data_report(self, client, test_report_with_chart_data): + request = GetReportByIdRequest(report_id=test_report_with_chart_data) + + response = client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.columns is not None + assert response.data.rows is not None + + @pytest.mark.flaky(reruns=3) + async def test_with_chart_data_report_async(self, async_client, test_report_with_chart_data): + request = GetReportByIdRequest(report_id=test_report_with_chart_data) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_request_chart_data(self, client, test_report_with_chart_data): + request = GetReportByIdRequest( + report_id=test_report_with_chart_data, + data_type=ReportDataType.ChartData.name, + ) + + response = client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert response.data.columns is not None + assert response.data.rows is not None + + @pytest.mark.flaky(reruns=3) + async def test_request_chart_data_async(self, async_client, test_report_with_chart_data): + request = GetReportByIdRequest( + report_id=test_report_with_chart_data, + data_type=ReportDataType.ChartData.name, + ) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + + @pytest.mark.flaky(reruns=3) + def test_chart_data_on_report_without_returns_400(self, client, test_report): + request = GetReportByIdRequest( + report_id=test_report, + data_type=ReportDataType.ChartData.name, + ) + + response = client.get_report_by_id(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_chart_data_on_report_without_returns_400_async(self, async_client, test_report): + request = GetReportByIdRequest( + report_id=test_report, + data_type=ReportDataType.ChartData.name, + ) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + request = GetReportByIdRequest(report_id=1) + + response = bad.get_report_by_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + request = GetReportByIdRequest(report_id=1) + + response = await bad.get_report_by_id(request) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client): + request = GetReportByIdRequest(report_id=1) + + response = client.get_report_by_id(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client): + request = GetReportByIdRequest(report_id=1) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_not_found_returns_404(self, client): + request = GetReportByIdRequest(report_id=0) + + response = client.get_report_by_id(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_not_found_returns_404_async(self, async_client): + request = GetReportByIdRequest(report_id=0) + + response = await async_client.get_report_by_id(request) + + assert response.status_code == 404 + assert not response.is_successful + assert response.data is None + + +class TestGetReportsByAppId: + @pytest.mark.flaky(reruns=3) + def test_sync(self, client, test_survey_id): + response = client.get_reports_by_app_id(app_id=test_survey_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.message is None + assert response.data is not None + assert response.data.page_number is not None + assert response.data.page_size is not None + assert response.data.total_pages is not None + assert response.data.total_records is not None + assert response.data.reports is not None + assert len(response.data.reports) > 0 + + for report in response.data.reports: + assert report.app_id is not None + assert report.id is not None + assert report.name is not None + + @pytest.mark.flaky(reruns=3) + async def test_async(self, async_client, test_survey_id): + response = await async_client.get_reports_by_app_id(app_id=test_survey_id) + + assert response.status_code == 200 + assert response.is_successful + assert response.data is not None + assert len(response.data.reports) > 0 + + @pytest.mark.flaky(reruns=3) + def test_invalid_page_size_returns_400(self, client, test_survey_id): + response = client.get_reports_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=-1), + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_page_size_returns_400_async(self, async_client, test_survey_id): + response = await async_client.get_reports_by_app_id( + app_id=test_survey_id, + paging_request=PagingRequest(page_number=1, page_size=-1), + ) + + assert response.status_code == 400 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_invalid_api_key_returns_401(self, base_url): + from onspring_api_sdk import OnspringClient + + bad = OnspringClient(base_url, "invalid") + response = bad.get_reports_by_app_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_invalid_api_key_returns_401_async(self, base_url): + from onspring_api_sdk import AsyncOnspringClient + + bad = AsyncOnspringClient(base_url, "invalid") + response = await bad.get_reports_by_app_id(app_id=1) + + assert response.status_code == 401 + assert not response.is_successful + assert response.data is None + + @pytest.mark.flaky(reruns=3) + def test_no_access_returns_403(self, client, test_app_id_no_access): + response = client.get_reports_by_app_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.message is not None + assert response.data is None + + @pytest.mark.flaky(reruns=3) + async def test_no_access_returns_403_async(self, async_client, test_app_id_no_access): + response = await async_client.get_reports_by_app_id(app_id=test_app_id_no_access) + + assert response.status_code == 403 + assert not response.is_successful + assert response.data is None diff --git a/tests/integration/testdata/test-attachment.txt b/tests/integration/testdata/test-attachment.txt new file mode 100644 index 0000000..3eae1d0 --- /dev/null +++ b/tests/integration/testdata/test-attachment.txt @@ -0,0 +1 @@ +This is a test attachment. \ No newline at end of file diff --git a/tests/integration/testdata/test-image.jpeg b/tests/integration/testdata/test-image.jpeg new file mode 100644 index 0000000..b493033 Binary files /dev/null and b/tests/integration/testdata/test-image.jpeg differ diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/utils/add_record.py b/tests/integration/utils/add_record.py new file mode 100644 index 0000000..19b9e9a --- /dev/null +++ b/tests/integration/utils/add_record.py @@ -0,0 +1,33 @@ +import os + +import pytest + +from onspring_api_sdk import OnspringClient +from onspring_api_sdk.models import Record, StringFieldValue + + +def add_record(base_url: str, api_key: str) -> int: + client = OnspringClient(base_url, api_key) + + survey_id = os.environ.get("TEST_SURVEY_ID") + text_field = os.environ.get("TEST_TEXT_FIELD") + + if survey_id is None: + pytest.fail("TEST_SURVEY_ID is not defined") + if text_field is None: + pytest.fail("TEST_TEXT_FIELD is not defined") + + app_id = int(survey_id) + field_id = int(text_field) + + record = Record( + app_id=app_id, + fields=[StringFieldValue(field_id=field_id, value="test")], + ) + + response = client.add_or_update_record(record) + + if response.data is None or response.data.id is None: + pytest.fail("Record ID is not defined") + + return response.data.id diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..5767563 --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,1344 @@ +import pytest +import respx +from httpx import Response + +from onspring_api_sdk import AsyncOnspringClient +from onspring_api_sdk.errors import ( + OnspringAuthenticationError, + OnspringError, + OnspringNotFoundError, + OnspringRateLimitError, +) +from onspring_api_sdk.models import ( + AddOrUpdateListItemResponse, + AddOrUpdateRecordResponse, + ApiResponse, + GetAppByIdResponse, + GetAppsByIdsResponse, + GetAppsResponse, + GetBatchRecordsResponse, + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + GetFileByIdResponse, + GetFileInfoByIdResponse, + GetRecordByIdRequest, + GetRecordsByAppRequest, + GetRecordsResponse, + GetReportByIdRequest, + GetReportByIdResponse, + GetReportsByAppIdResponse, + Record, + SaveFileRequest, + SaveFileResponse, +) + +from .conftest import ( + MOCK_APP, + MOCK_APPS_BATCH_RESPONSE, + MOCK_APPS_RESPONSE, + MOCK_FIELD, + MOCK_FIELDS_BATCH_RESPONSE, + MOCK_FIELDS_RESPONSE, + MOCK_FILE_INFO, + MOCK_LIST_ITEM_RESPONSE, + MOCK_MESSAGE_RESPONSE, + MOCK_RECORD, + MOCK_RECORDS_BATCH_RESPONSE, + MOCK_RECORDS_RESPONSE, + MOCK_REPORT_RESPONSE, + MOCK_REPORTS_BY_APP_RESPONSE, + MOCK_SAVE_FILE_RESPONSE, + MOCK_SAVE_RECORD_RESPONSE, + TEST_URL, + create_temp_file, +) + + +def _mock_json(status: int, json: dict | list | None = None) -> Response: + return Response(status, json=json) + + +def _assert_error(response: ApiResponse, status: int, message: str | None) -> None: + assert response.status_code == status + assert response.is_successful is False + assert response.data is None + assert response.message == message + + +class TestCanConnect: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Ping").mock(return_value=Response(200)) + + assert await async_client.can_connect() is True + + async def test_failure(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Ping").mock(return_value=Response(401)) + + assert await async_client.can_connect() is False + + async def test_async_context_manager(self, async_client: AsyncOnspringClient): + async with async_client as cm: + assert cm is async_client + + assert async_client.client.is_closed + + async def test_aclose(self, async_client: AsyncOnspringClient): + await async_client.aclose() + assert async_client.client.is_closed + + +class TestGetApps: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(200, json=MOCK_APPS_RESPONSE)) + + response = await async_client.get_apps() + + assert response.is_successful + assert isinstance(response.data, GetAppsResponse) + assert len(response.data.apps) == 2 + assert response.data.apps[0].name == "Test App" + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(400)) + + _assert_error(await async_client.get_apps(), 400, "Invalid paging information") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(401)) + + _assert_error(await async_client.get_apps(), 401, "Unauthorized request") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(418)) + + _assert_error(await async_client.get_apps(), 418, None) + + async def test_with_explicit_paging(self, async_client: AsyncOnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(200, json=MOCK_APPS_RESPONSE)) + + paging = PagingRequest(page_number=2, page_size=10) + response = await async_client.get_apps(paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetAppsResponse) + + +class TestGetAppById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(200, json=MOCK_APP)) + + response = await async_client.get_app_by_id(1) + + assert response.is_successful + assert isinstance(response.data, GetAppByIdResponse) + assert response.data.app.id == 1 + assert response.data.app.name == "Test App" + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(401)) + + _assert_error(await async_client.get_app_by_id(1), 401, "Unauthorized request") + + async def test_403(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(403)) + + _assert_error(await async_client.get_app_by_id(1), 403, "Client does not have read access to the app") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/999").mock(return_value=Response(404)) + + _assert_error(await async_client.get_app_by_id(999), 404, "App could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(418)) + + _assert_error(await async_client.get_app_by_id(1), 418, None) + + +class TestGetAppsByIds: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(200, json=MOCK_APPS_BATCH_RESPONSE)) + + response = await async_client.get_apps_by_ids([1, 2]) + + assert response.is_successful + assert isinstance(response.data, GetAppsByIdsResponse) + assert response.data.count == 2 + + async def test_type_check(self, async_client: AsyncOnspringClient): + response = await async_client.get_apps_by_ids("not a list") + + _assert_error(response, 400, "App ids should be of type list or tuple") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(401)) + + _assert_error(await async_client.get_apps_by_ids([1]), 401, "Unauthorized request") + + async def test_403(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(403)) + + _assert_error(await async_client.get_apps_by_ids([1]), 403, "Client does not have read access to the app") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(418)) + + _assert_error(await async_client.get_apps_by_ids([1]), 418, None) + + +class TestGetFieldById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(200, json=MOCK_FIELD)) + + response = await async_client.get_field_by_id(1) + + assert response.is_successful + assert isinstance(response.data, GetFieldByIdResponse) + assert response.data.field.id == 1 + assert response.data.field.name == "Test Field" + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(401)) + + _assert_error(await async_client.get_field_by_id(1), 401, "Unauthorized request") + + async def test_403(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(403)) + + _assert_error(await async_client.get_field_by_id(1), 403, "Client does not have read access to the field") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/999").mock(return_value=Response(404)) + + _assert_error(await async_client.get_field_by_id(999), 404, "Field could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(418)) + + _assert_error(await async_client.get_field_by_id(1), 418, None) + + +class TestGetFieldsByIds: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(200, json=MOCK_FIELDS_BATCH_RESPONSE)) + + response = await async_client.get_fields_by_ids([1, 2]) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByIdsResponse) + assert response.data.count == 2 + + async def test_type_check(self, async_client: AsyncOnspringClient): + response = await async_client.get_fields_by_ids("not a list") + + _assert_error(response, 400, "Field ids should be of type list or tuple") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(401)) + + _assert_error(await async_client.get_fields_by_ids([1]), 401, "Unauthorized request") + + async def test_403(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(403)) + + _assert_error( + await async_client.get_fields_by_ids([1]), 403, "Client does not have read access to the field(s)" + ) + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(404)) + + _assert_error(await async_client.get_fields_by_ids([1]), 404, "Field(s) could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(418)) + + _assert_error(await async_client.get_fields_by_ids([1]), 418, None) + + +class TestGetFieldsByAppId: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(200, json=MOCK_FIELDS_RESPONSE)) + + response = await async_client.get_fields_by_app_id(1) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByAppIdResponse) + assert len(response.data.fields) == 2 + assert response.data.fields[0].id == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(400)) + + _assert_error(await async_client.get_fields_by_app_id(1), 400, "Invalid paging information") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(401)) + + _assert_error(await async_client.get_fields_by_app_id(1), 401, "Unauthorized request") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(418)) + + _assert_error(await async_client.get_fields_by_app_id(1), 418, None) + + async def test_with_explicit_paging(self, async_client: AsyncOnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(200, json=MOCK_FIELDS_RESPONSE)) + + paging = PagingRequest(page_number=2, page_size=10) + response = await async_client.get_fields_by_app_id(1, paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByAppIdResponse) + + +class TestGetFileInfoById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(200, json=MOCK_FILE_INFO) + ) + + response = await async_client.get_file_info_by_id(1, 2, 3) + + assert response.is_successful + assert isinstance(response.data, GetFileInfoByIdResponse) + assert response.data.file_info.name == "test.txt" + assert response.data.file_info.content_type == "text/plain" + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(400)) + + _assert_error( + await async_client.get_file_info_by_id(1, 2, 3), 400, "Request is invalid based on underlying data" + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(401)) + + _assert_error(await async_client.get_file_info_by_id(1, 2, 3), 401, "Unauthorized request") + + async def test_403(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(403)) + + _assert_error( + await async_client.get_file_info_by_id(1, 2, 3), 403, "Client does not have read access to the file" + ) + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(404)) + + _assert_error(await async_client.get_file_info_by_id(1, 2, 3), 404, "File could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(418)) + + _assert_error(await async_client.get_file_info_by_id(1, 2, 3), 418, None) + + +class TestDeleteFileById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(204)) + + response = await async_client.delete_file_by_id(1, 2, 3) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "File deleted successfully" + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(400)) + + _assert_error( + await async_client.delete_file_by_id(1, 2, 3), 400, "Request is invalid based on underlying data" + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(401)) + + _assert_error(await async_client.delete_file_by_id(1, 2, 3), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.delete_file_by_id(1, 2, 3) + + _assert_error(response, 403, "An error occurred") + + async def test_404_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(404, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.delete_file_by_id(1, 2, 3) + + _assert_error(response, 404, "An error occurred") + + async def test_500(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(500)) + + _assert_error( + await async_client.delete_file_by_id(1, 2, 3), 500, "File could not be deleted due to internal error" + ) + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(418)) + + _assert_error(await async_client.delete_file_by_id(1, 2, 3), 418, None) + + +class TestGetFileById: + def _mock_file_response(self, headers: dict | None = None) -> Response: + h = { + "content-disposition": "filename=test.txt", + "content-type": "text/plain", + "content-length": "12", + } + + if headers: + h.update(headers) + + return Response(200, headers=h, content=b"Hello World!") + + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response() + ) + + response = await async_client.get_file_by_id(1, 2, 3) + + assert response.is_successful + assert isinstance(response.data, GetFileByIdResponse) + assert response.data.file.name == "test.txt" + assert response.data.file.content_type == "text/plain" + assert response.data.file.content_length == 12 + assert response.data.file.content == b"Hello World!" + + async def test_quoted_filename(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response({"content-disposition": 'attachment; filename="quoted.pdf"'}) + ) + + response = await async_client.get_file_by_id(1, 2, 3) + + assert response.data.file.name == "quoted.pdf" + + async def test_success_onspring_fallback(self, async_client: AsyncOnspringClient): + """When no content-disposition header, fall back to 'OnspringFile'.""" + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response({"content-disposition": ""}) + ) + + response = await async_client.get_file_by_id(1, 2, 3) + + assert response.data.file.name == "OnspringFile" + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(400)) + + _assert_error( + await async_client.get_file_by_id(1, 2, 3), 400, "Request is invalid based on underlying data" + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(401)) + + _assert_error(await async_client.get_file_by_id(1, 2, 3), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.get_file_by_id(1, 2, 3) + + _assert_error(response, 403, "An error occurred") + + async def test_404_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=Response(404, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.get_file_by_id(1, 2, 3) + + _assert_error(response, 404, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(418)) + + _assert_error(await async_client.get_file_by_id(1, 2, 3), 418, None) + + +class TestSaveFile: + async def test_success(self, async_client: AsyncOnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(201, json=MOCK_SAVE_FILE_RESPONSE)) + + response = await async_client.save_file(request) + + assert response.is_successful + assert response.status_code == 201 + assert isinstance(response.data, SaveFileResponse) + assert response.data.id == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(400)) + + _assert_error(await async_client.save_file(request), 400, "Request is invalid based on underlying data") + + async def test_401(self, async_client: AsyncOnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(401)) + + _assert_error(await async_client.save_file(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = await async_client.save_file(request) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(418)) + + _assert_error(await async_client.save_file(request), 418, None) + + +class TestAddOrUpdateListItem: + async def test_200_update(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(200, json=MOCK_LIST_ITEM_RESPONSE)) + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Updated Item") + response = await async_client.add_or_update_list_item(request) + + assert response.is_successful + assert response.status_code == 200 + assert response.message == "Existing list value successfully updated" + assert isinstance(response.data, AddOrUpdateListItemResponse) + + async def test_201_create(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(201, json=MOCK_LIST_ITEM_RESPONSE)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="New Item") + response = await async_client.add_or_update_list_item(request) + + assert response.is_successful + assert response.status_code == 201 + assert response.message == "New list value successfully added" + assert isinstance(response.data, AddOrUpdateListItemResponse) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(401)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + + _assert_error(await async_client.add_or_update_list_item(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + response = await async_client.add_or_update_list_item(request) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(418)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + + _assert_error(await async_client.add_or_update_list_item(request), 418, None) + + +class TestDeleteListItem: + async def test_success(self, async_client: AsyncOnspringClient): + item_id = "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84" + + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/{item_id}").mock(return_value=Response(204)) + + response = await async_client.delete_list_item(100, item_id) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Item deleted successfully" + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(401)) + + _assert_error(await async_client.delete_list_item(100, "test"), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.delete_list_item(100, "test") + + _assert_error(response, 403, "An error occurred") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(404)) + + _assert_error(await async_client.delete_list_item(100, "test"), 404, "List/item could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(418)) + + _assert_error(await async_client.delete_list_item(100, "test"), 418, None) + + +class TestGetRecordsByAppRequestDefaults: + async def test_default_page_values(self): + from onspring_api_sdk.models import GetRecordsByAppRequest + + request = GetRecordsByAppRequest(app_id=100) + + assert request.page_number == 1 + assert request.page_size == 50 + + +class TestGetRecordsByAppId: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + request = GetRecordsByAppRequest(app_id=100) + + response = await async_client.get_records_by_app_id(request) + + assert response.is_successful + assert isinstance(response.data, GetRecordsResponse) + assert len(response.data.records) == 1 + assert response.data.records[0].record_id == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(400)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error( + await async_client.get_records_by_app_id(request), + 400, + "Invalid paging information/size of the data requested was too large.", + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(401)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error(await async_client.get_records_by_app_id(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + request = GetRecordsByAppRequest(app_id=100) + + response = await async_client.get_records_by_app_id(request) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(418)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error(await async_client.get_records_by_app_id(request), 418, None) + + +class TestGetRecordById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(200, json=MOCK_RECORD)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + response = await async_client.get_record_by_id(request) + + assert response.is_successful + assert isinstance(response.data, Record) + assert response.data.record_id == 1 + assert len(response.data.fields) == 2 + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(401)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + _assert_error(await async_client.get_record_by_id(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + response = await async_client.get_record_by_id(request) + + _assert_error(response, 403, "An error occurred") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/999").mock(return_value=Response(404)) + + request = GetRecordByIdRequest(app_id=100, record_id=999) + + _assert_error(await async_client.get_record_by_id(request), 404, "Record could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(418)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + _assert_error(await async_client.get_record_by_id(request), 418, None) + + +class TestDeleteRecordById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(204)) + + response = await async_client.delete_record_by_id(100, 1) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Record deleted successfully" + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(401)) + + _assert_error(await async_client.delete_record_by_id(100, 1), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = await async_client.delete_record_by_id(100, 1) + + _assert_error(response, 403, "An error occurred") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/999").mock(return_value=Response(404)) + + _assert_error(await async_client.delete_record_by_id(100, 999), 404, "Record could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(418)) + + _assert_error(await async_client.delete_record_by_id(100, 1), 418, None) + + +class TestGetRecordsByIds: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock( + return_value=Response(200, json=MOCK_RECORDS_BATCH_RESPONSE) + ) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = await async_client.get_records_by_ids(request) + + assert response.is_successful + assert isinstance(response.data, GetBatchRecordsResponse) + assert response.data.count == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(400)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error( + await async_client.get_records_by_ids(request), + 400, + "Batch request is invalid/size of the data requested was too large.", + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(401)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.get_records_by_ids(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = await async_client.get_records_by_ids(request) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(418)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.get_records_by_ids(request), 418, None) + + +class TestQueryRecordsRequestDefaults: + async def test_default_page_values(self): + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="test") + + assert request.page_number == 1 + assert request.page_size == 50 + + +class TestQueryRecords: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + response = await async_client.query_records(request) + + assert response.is_successful + assert isinstance(response.data, GetRecordsResponse) + assert len(response.data.records) == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(400)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error( + await async_client.query_records(request), + 400, + "Query request is invalid/size of the data requested was too large.", + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(401)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error(await async_client.query_records(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + response = await async_client.query_records(request) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(418)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error(await async_client.query_records(request), 418, None) + + async def test_request_body_excludes_page_params(self, async_client: AsyncOnspringClient): + with respx.mock: + route = respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test", page_number=2, page_size=10) + await async_client.query_records(request) + + body = route.calls[0].request.content + assert b"pageNumber" not in body + assert b"pageSize" not in body + + +class TestAddOrUpdateRecord: + def _make_record(self) -> Record: + from onspring_api_sdk.models import StringFieldValue + + return Record( + appId=100, + fieldData=[StringFieldValue(fieldId=1, value="test")], + ) + + async def test_200_update(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + + response = await async_client.add_or_update_record(self._make_record()) + + assert response.is_successful + assert response.status_code == 200 + assert response.message == "Record updated successfully" + assert isinstance(response.data, AddOrUpdateRecordResponse) + assert response.data.id == 1 + + async def test_201_create(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(201, json=MOCK_SAVE_RECORD_RESPONSE)) + + response = await async_client.add_or_update_record(self._make_record()) + + assert response.is_successful + assert response.status_code == 201 + assert response.message == "Record created successfully" + assert isinstance(response.data, AddOrUpdateRecordResponse) + assert response.data.id == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(400)) + + _assert_error(await async_client.add_or_update_record(self._make_record()), 400, "Request data is invalid") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(401)) + + _assert_error(await async_client.add_or_update_record(self._make_record()), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = await async_client.add_or_update_record(self._make_record()) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(418)) + + _assert_error(await async_client.add_or_update_record(self._make_record()), 418, None) + + async def test_with_guid_field(self, async_client: AsyncOnspringClient): + import uuid + + from onspring_api_sdk.models import GuidFieldValue, Record + + record = Record( + appId=100, + fieldData=[GuidFieldValue(fieldId=1, value=uuid.UUID("12345678-1234-5678-1234-567812345678"))], + ) + + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + response = await async_client.add_or_update_record(record) + + assert response.is_successful + + async def test_payload_excludes_field_data(self, async_client: AsyncOnspringClient): + from onspring_api_sdk.models import Record, StringFieldValue + + record = Record( + appId=100, + fieldData=[StringFieldValue(fieldId=1, value="test")], + ) + + with respx.mock: + route = respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + await async_client.add_or_update_record(record) + + body = route.calls[0].request.content + assert b"fieldData" not in body + + +class TestDeleteRecordsByIds: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(204)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1, 2]) + + response = await async_client.delete_records_by_ids(request) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Record(s) deleted successfully" + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(400)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.delete_records_by_ids(request), 400, "Invalid request provided") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(401)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.delete_records_by_ids(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = await async_client.delete_records_by_ids(request) + + _assert_error(response, 403, "An error occurred") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(404)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.delete_records_by_ids(request), 404, "Records could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(418)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(await async_client.delete_records_by_ids(request), 418, None) + + +class TestGetReportById: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(200, json=MOCK_REPORT_RESPONSE)) + + request = GetReportByIdRequest(report_id=53) + + response = await async_client.get_report_by_id(request) + + assert response.is_successful + assert isinstance(response.data, GetReportByIdResponse) + assert len(response.data.columns) == 2 + assert len(response.data.rows) == 1 + assert response.data.rows[0].record_id == 1 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(400)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(await async_client.get_report_by_id(request), 400, "Invalid request based on underlying data") + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(401)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(await async_client.get_report_by_id(request), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + request = GetReportByIdRequest(report_id=53) + + response = await async_client.get_report_by_id(request) + + _assert_error(response, 403, "An error occurred") + + async def test_404(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/999").mock(return_value=Response(404)) + + request = GetReportByIdRequest(report_id=999) + + _assert_error(await async_client.get_report_by_id(request), 404, "Report could not be found") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(418)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(await async_client.get_report_by_id(request), 418, None) + + async def test_params_excludes_report_id(self, async_client: AsyncOnspringClient): + request = GetReportByIdRequest(report_id=53) + + with respx.mock: + route = respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(200, json=MOCK_REPORT_RESPONSE)) + await async_client.get_report_by_id(request) + + assert "reportId" not in route.calls[0].request.url.params + + async def test_403_empty_body(self, async_client: AsyncOnspringClient): + request = GetReportByIdRequest(report_id=53) + + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(403, content=b"")) + response = await async_client.get_report_by_id(request) + + assert response.status_code == 403 + assert response.is_successful is False + + +class TestGetReportsByAppId: + async def test_success(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock( + return_value=Response(200, json=MOCK_REPORTS_BY_APP_RESPONSE) + ) + + response = await async_client.get_reports_by_app_id(10) + + assert response.is_successful + assert isinstance(response.data, GetReportsByAppIdResponse) + assert len(response.data.reports) == 1 + assert response.data.reports[0].id == 53 + + async def test_400(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(400)) + + _assert_error( + await async_client.get_reports_by_app_id(10), 400, "Client does not have read access to the app." + ) + + async def test_401(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(401)) + + _assert_error(await async_client.get_reports_by_app_id(10), 401, "Unauthorized request") + + async def test_403_with_message(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = await async_client.get_reports_by_app_id(10) + + _assert_error(response, 403, "An error occurred") + + async def test_fallthrough(self, async_client: AsyncOnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(418)) + + _assert_error(await async_client.get_reports_by_app_id(10), 418, None) + + async def test_with_explicit_paging(self, async_client: AsyncOnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock( + return_value=Response(200, json=MOCK_REPORTS_BY_APP_RESPONSE) + ) + + paging = PagingRequest(page_number=2, page_size=10) + response = await async_client.get_reports_by_app_id(10, paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetReportsByAppIdResponse) + + +class TestRaiseForStatus: + async def test_401_raises_authentication_error(self): + response = ApiResponse(status_code=401, message="Unauthorized") + + with pytest.raises(OnspringAuthenticationError, match="Unauthorized"): + response.raise_for_status() + + async def test_403_raises_authentication_error(self): + response = ApiResponse(status_code=403, message="Forbidden") + + with pytest.raises(OnspringAuthenticationError, match="Forbidden"): + response.raise_for_status() + + async def test_404_raises_not_found_error(self): + response = ApiResponse(status_code=404, message="Not Found") + + with pytest.raises(OnspringNotFoundError, match="Not Found"): + response.raise_for_status() + + async def test_429_raises_rate_limit_error(self): + response = ApiResponse(status_code=429, message="Rate limited") + + with pytest.raises(OnspringRateLimitError, match="Rate limited"): + response.raise_for_status() + + async def test_418_raises_generic_error(self): + response = ApiResponse(status_code=418, message="Teapot") + + with pytest.raises(OnspringError, match="Teapot"): + response.raise_for_status() + + async def test_success_does_not_raise(self): + response = ApiResponse(status_code=200, data="ok") + response.raise_for_status() diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..0829774 --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,1352 @@ +import pytest +import respx +from httpx import Response + +from onspring_api_sdk import OnspringClient +from onspring_api_sdk.errors import ( + OnspringAuthenticationError, + OnspringError, + OnspringNotFoundError, + OnspringRateLimitError, +) +from onspring_api_sdk.models import ( + AddOrUpdateListItemResponse, + AddOrUpdateRecordResponse, + ApiResponse, + GetAppByIdResponse, + GetAppsByIdsResponse, + GetAppsResponse, + GetBatchRecordsResponse, + GetFieldByIdResponse, + GetFieldsByAppIdResponse, + GetFieldsByIdsResponse, + GetFileByIdResponse, + GetFileInfoByIdResponse, + GetRecordByIdRequest, + GetRecordsByAppRequest, + GetRecordsResponse, + GetReportByIdRequest, + GetReportByIdResponse, + GetReportsByAppIdResponse, + Record, + SaveFileRequest, + SaveFileResponse, +) + +from .conftest import ( + MOCK_APP, + MOCK_APPS_BATCH_RESPONSE, + MOCK_APPS_RESPONSE, + MOCK_FIELD, + MOCK_FIELDS_BATCH_RESPONSE, + MOCK_FIELDS_RESPONSE, + MOCK_FILE_INFO, + MOCK_LIST_FIELD, + MOCK_LIST_ITEM_RESPONSE, + MOCK_MESSAGE_RESPONSE, + MOCK_RECORD, + MOCK_RECORDS_BATCH_RESPONSE, + MOCK_RECORDS_RESPONSE, + MOCK_REPORT_RESPONSE, + MOCK_REPORTS_BY_APP_RESPONSE, + MOCK_SAVE_FILE_RESPONSE, + MOCK_SAVE_RECORD_RESPONSE, + TEST_URL, + create_temp_file, +) + + +def _mock_json(status: int, json: dict | list | None = None) -> Response: + return Response(status, json=json) + + +def _assert_error(response: ApiResponse, status: int, message: str | None) -> None: + assert response.status_code == status + assert response.is_successful is False + assert response.data is None + assert response.message == message + + +class TestCanConnect: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Ping").mock(return_value=Response(200)) + + assert client.can_connect() is True + + def test_failure(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Ping").mock(return_value=Response(401)) + + assert client.can_connect() is False + + def test_context_manager(self, client: OnspringClient): + with client as cm: + assert cm is client + + assert client.client.is_closed + + def test_close(self, client: OnspringClient): + client.close() + assert client.client.is_closed + + +class TestGetApps: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(200, json=MOCK_APPS_RESPONSE)) + + response = client.get_apps() + + assert response.is_successful + assert isinstance(response.data, GetAppsResponse) + assert len(response.data.apps) == 2 + assert response.data.apps[0].name == "Test App" + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(400)) + + _assert_error(client.get_apps(), 400, "Invalid paging information") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(401)) + + _assert_error(client.get_apps(), 401, "Unauthorized request") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(418)) + + _assert_error(client.get_apps(), 418, None) + + def test_with_explicit_paging(self, client: OnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Apps").mock(return_value=Response(200, json=MOCK_APPS_RESPONSE)) + + paging = PagingRequest(page_number=2, page_size=10) + response = client.get_apps(paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetAppsResponse) + + +class TestGetAppById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(200, json=MOCK_APP)) + + response = client.get_app_by_id(1) + + assert response.is_successful + assert isinstance(response.data, GetAppByIdResponse) + assert response.data.app.id == 1 + assert response.data.app.name == "Test App" + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(401)) + + _assert_error(client.get_app_by_id(1), 401, "Unauthorized request") + + def test_403(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(403)) + + _assert_error(client.get_app_by_id(1), 403, "Client does not have read access to the app") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/999").mock(return_value=Response(404)) + + _assert_error(client.get_app_by_id(999), 404, "App could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Apps/id/1").mock(return_value=Response(418)) + + _assert_error(client.get_app_by_id(1), 418, None) + + +class TestGetAppsByIds: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(200, json=MOCK_APPS_BATCH_RESPONSE)) + + response = client.get_apps_by_ids([1, 2]) + + assert response.is_successful + assert isinstance(response.data, GetAppsByIdsResponse) + assert response.data.count == 2 + + def test_type_check(self, client: OnspringClient): + response = client.get_apps_by_ids("not a list") + + _assert_error(response, 400, "App ids should be of type list or tuple") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(401)) + + _assert_error(client.get_apps_by_ids([1]), 401, "Unauthorized request") + + def test_403(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(403)) + + _assert_error(client.get_apps_by_ids([1]), 403, "Client does not have read access to the app") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Apps/batch-get").mock(return_value=Response(418)) + + _assert_error(client.get_apps_by_ids([1]), 418, None) + + +class TestGetFieldById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(200, json=MOCK_FIELD)) + + response = client.get_field_by_id(1) + + assert response.is_successful + assert isinstance(response.data, GetFieldByIdResponse) + assert response.data.field.id == 1 + assert response.data.field.name == "Test Field" + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(401)) + + _assert_error(client.get_field_by_id(1), 401, "Unauthorized request") + + def test_403(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(403)) + + _assert_error(client.get_field_by_id(1), 403, "Client does not have read access to the field") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/999").mock(return_value=Response(404)) + + _assert_error(client.get_field_by_id(999), 404, "Field could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/1").mock(return_value=Response(418)) + + _assert_error(client.get_field_by_id(1), 418, None) + + def test_list_field_values(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/id/2").mock(return_value=Response(200, json=MOCK_LIST_FIELD)) + + response = client.get_field_by_id(2) + + assert response.data.field.values is not None + assert response.data.field.values[0].name == "list_value_1" + assert response.data.field.values[0].numeric_value == 1 + assert response.data.field.values[0].color == "#008e8e" + + +class TestGetFieldsByIds: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(200, json=MOCK_FIELDS_BATCH_RESPONSE)) + + response = client.get_fields_by_ids([1, 2]) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByIdsResponse) + assert response.data.count == 2 + + def test_type_check(self, client: OnspringClient): + response = client.get_fields_by_ids("not a list") + + _assert_error(response, 400, "Field ids should be of type list or tuple") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(401)) + + _assert_error(client.get_fields_by_ids([1]), 401, "Unauthorized request") + + def test_403(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(403)) + + _assert_error(client.get_fields_by_ids([1]), 403, "Client does not have read access to the field(s)") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(404)) + + _assert_error(client.get_fields_by_ids([1]), 404, "Field(s) could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Fields/batch-get").mock(return_value=Response(418)) + + _assert_error(client.get_fields_by_ids([1]), 418, None) + + +class TestGetFieldsByAppId: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(200, json=MOCK_FIELDS_RESPONSE)) + + response = client.get_fields_by_app_id(1) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByAppIdResponse) + assert len(response.data.fields) == 2 + assert response.data.fields[0].id == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(400)) + + _assert_error(client.get_fields_by_app_id(1), 400, "Invalid paging information") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(401)) + + _assert_error(client.get_fields_by_app_id(1), 401, "Unauthorized request") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(418)) + + _assert_error(client.get_fields_by_app_id(1), 418, None) + + def test_with_explicit_paging(self, client: OnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Fields/appId/1").mock(return_value=Response(200, json=MOCK_FIELDS_RESPONSE)) + + paging = PagingRequest(page_number=2, page_size=10) + response = client.get_fields_by_app_id(1, paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetFieldsByAppIdResponse) + + +class TestGetFileInfoById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(200, json=MOCK_FILE_INFO) + ) + + response = client.get_file_info_by_id(1, 2, 3) + + assert response.is_successful + assert isinstance(response.data, GetFileInfoByIdResponse) + assert response.data.file_info.name == "test.txt" + assert response.data.file_info.content_type == "text/plain" + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(400)) + + _assert_error(client.get_file_info_by_id(1, 2, 3), 400, "Request is invalid based on underlying data") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(401)) + + _assert_error(client.get_file_info_by_id(1, 2, 3), 401, "Unauthorized request") + + def test_403(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(403)) + + _assert_error(client.get_file_info_by_id(1, 2, 3), 403, "Client does not have read access to the file") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(404)) + + _assert_error(client.get_file_info_by_id(1, 2, 3), 404, "File could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(418)) + + _assert_error(client.get_file_info_by_id(1, 2, 3), 418, None) + + +class TestDeleteFileById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(204)) + + response = client.delete_file_by_id(1, 2, 3) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "File deleted successfully" + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(400)) + + _assert_error(client.delete_file_by_id(1, 2, 3), 400, "Request is invalid based on underlying data") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(401)) + + _assert_error(client.delete_file_by_id(1, 2, 3), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.delete_file_by_id(1, 2, 3) + + _assert_error(response, 403, "An error occurred") + + def test_404_with_message(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock( + return_value=Response(404, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.delete_file_by_id(1, 2, 3) + + _assert_error(response, 404, "An error occurred") + + def test_500(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(500)) + + _assert_error(client.delete_file_by_id(1, 2, 3), 500, "File could not be deleted due to internal error") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3").mock(return_value=Response(418)) + + _assert_error(client.delete_file_by_id(1, 2, 3), 418, None) + + +class TestGetFileById: + def _mock_file_response(self, headers: dict | None = None) -> Response: + h = { + "content-disposition": "filename=test.txt", + "content-type": "text/plain", + "content-length": "12", + } + + if headers: + h.update(headers) + + return Response(200, headers=h, content=b"Hello World!") + + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response() + ) + + response = client.get_file_by_id(1, 2, 3) + + assert response.is_successful + assert isinstance(response.data, GetFileByIdResponse) + assert response.data.file.name == "test.txt" + assert response.data.file.content_type == "text/plain" + assert response.data.file.content_length == 12 + assert response.data.file.content == b"Hello World!" + + def test_quoted_filename(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response({"content-disposition": 'attachment; filename="quoted.pdf"'}) + ) + + response = client.get_file_by_id(1, 2, 3) + + assert response.data.file.name == "quoted.pdf" + + def test_success_onspring_fallback(self, client: OnspringClient): + """When no content-disposition header, fall back to 'OnspringFile'.""" + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=self._mock_file_response({"content-disposition": ""}) + ) + + response = client.get_file_by_id(1, 2, 3) + + assert response.data.file.name == "OnspringFile" + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(400)) + + _assert_error(client.get_file_by_id(1, 2, 3), 400, "Request is invalid based on underlying data") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(401)) + + _assert_error(client.get_file_by_id(1, 2, 3), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.get_file_by_id(1, 2, 3) + + _assert_error(response, 403, "An error occurred") + + def test_404_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock( + return_value=Response(404, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.get_file_by_id(1, 2, 3) + + _assert_error(response, 404, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Files/recordId/1/fieldId/2/fileId/3/file").mock(return_value=Response(418)) + + _assert_error(client.get_file_by_id(1, 2, 3), 418, None) + + +class TestSaveFile: + def test_success(self, client: OnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(201, json=MOCK_SAVE_FILE_RESPONSE)) + + response = client.save_file(request) + + assert response.is_successful + assert response.status_code == 201 + assert isinstance(response.data, SaveFileResponse) + assert response.data.id == 1 + + def test_400(self, client: OnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(400)) + + _assert_error(client.save_file(request), 400, "Request is invalid based on underlying data") + + def test_401(self, client: OnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(401)) + + _assert_error(client.save_file(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = client.save_file(request) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + file_path = create_temp_file() + request = SaveFileRequest( + recordId=1, + fieldId=2, + fileName="test.txt", + filePath=str(file_path), + contentType="text/plain", + ) + + with respx.mock: + respx.post(f"{TEST_URL}/Files").mock(return_value=Response(418)) + + _assert_error(client.save_file(request), 418, None) + + +class TestAddOrUpdateListItem: + def test_200_update(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(200, json=MOCK_LIST_ITEM_RESPONSE)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Updated Item") + + response = client.add_or_update_list_item(request) + + assert response.is_successful + assert response.status_code == 200 + assert response.message == "Existing list value successfully updated" + assert isinstance(response.data, AddOrUpdateListItemResponse) + + def test_201_create(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(201, json=MOCK_LIST_ITEM_RESPONSE)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="New Item") + + response = client.add_or_update_list_item(request) + + assert response.is_successful + assert response.status_code == 201 + assert response.message == "New list value successfully added" + assert isinstance(response.data, AddOrUpdateListItemResponse) + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(401)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + + _assert_error(client.add_or_update_list_item(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + + response = client.add_or_update_list_item(request) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Lists/id/100/items").mock(return_value=Response(418)) + + from onspring_api_sdk.models import ListItemRequest + + request = ListItemRequest(listId=100, name="Item") + + _assert_error(client.add_or_update_list_item(request), 418, None) + + +class TestDeleteListItem: + def test_success(self, client: OnspringClient): + item_id = "2c1af5b1-0f90-4378-b9a5-8b7e22f2bc84" + + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/{item_id}").mock(return_value=Response(204)) + + response = client.delete_list_item(100, item_id) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Item deleted successfully" + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(401)) + + _assert_error(client.delete_list_item(100, "test"), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.delete_list_item(100, "test") + + _assert_error(response, 403, "An error occurred") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(404)) + + _assert_error(client.delete_list_item(100, "test"), 404, "List/item could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Lists/id/100/itemId/test").mock(return_value=Response(418)) + + _assert_error(client.delete_list_item(100, "test"), 418, None) + + def test_endpoint_accepts_string(self): + from onspring_api_sdk.endpoints import delete_list_item_endpoint + + url = delete_list_item_endpoint("https://test.com", 1, "my-string-id") + assert url == "https://test.com/Lists/id/1/itemId/my-string-id" + + +class TestGetRecordsByAppRequestDefaults: + def test_default_page_values(self): + from onspring_api_sdk.models import GetRecordsByAppRequest + + request = GetRecordsByAppRequest(app_id=100) + + assert request.page_number == 1 + assert request.page_size == 50 + + +class TestGetRecordsByAppId: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + request = GetRecordsByAppRequest(app_id=100) + + response = client.get_records_by_app_id(request) + + assert response.is_successful + assert isinstance(response.data, GetRecordsResponse) + assert len(response.data.records) == 1 + assert response.data.records[0].record_id == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(400)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error( + client.get_records_by_app_id(request), + 400, + "Invalid paging information/size of the data requested was too large.", + ) + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(401)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error(client.get_records_by_app_id(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + request = GetRecordsByAppRequest(app_id=100) + + response = client.get_records_by_app_id(request) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100").mock(return_value=Response(418)) + + request = GetRecordsByAppRequest(app_id=100) + + _assert_error(client.get_records_by_app_id(request), 418, None) + + +class TestGetRecordById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(200, json=MOCK_RECORD)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + response = client.get_record_by_id(request) + + assert response.is_successful + assert isinstance(response.data, Record) + assert response.data.record_id == 1 + assert len(response.data.fields) == 2 + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(401)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + _assert_error(client.get_record_by_id(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + response = client.get_record_by_id(request) + + _assert_error(response, 403, "An error occurred") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/999").mock(return_value=Response(404)) + + request = GetRecordByIdRequest(app_id=100, record_id=999) + + _assert_error(client.get_record_by_id(request), 404, "Record could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(418)) + + request = GetRecordByIdRequest(app_id=100, record_id=1) + + _assert_error(client.get_record_by_id(request), 418, None) + + +class TestDeleteRecordById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(204)) + + response = client.delete_record_by_id(100, 1) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Record deleted successfully" + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(401)) + + _assert_error(client.delete_record_by_id(100, 1), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock( + return_value=Response(403, json=MOCK_MESSAGE_RESPONSE) + ) + + response = client.delete_record_by_id(100, 1) + + _assert_error(response, 403, "An error occurred") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/999").mock(return_value=Response(404)) + + _assert_error(client.delete_record_by_id(100, 999), 404, "Record could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.delete(f"{TEST_URL}/Records/appId/100/recordId/1").mock(return_value=Response(418)) + + _assert_error(client.delete_record_by_id(100, 1), 418, None) + + +class TestGetRecordsByIds: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock( + return_value=Response(200, json=MOCK_RECORDS_BATCH_RESPONSE) + ) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = client.get_records_by_ids(request) + + assert response.is_successful + assert isinstance(response.data, GetBatchRecordsResponse) + assert response.data.count == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(400)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error( + client.get_records_by_ids(request), + 400, + "Batch request is invalid/size of the data requested was too large.", + ) + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(401)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.get_records_by_ids(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = client.get_records_by_ids(request) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-get").mock(return_value=Response(418)) + + from onspring_api_sdk.models import GetBatchRecordsRequest + + request = GetBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.get_records_by_ids(request), 418, None) + + +class TestQueryRecordsRequestDefaults: + def test_default_page_values(self): + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="test") + + assert request.page_number == 1 + assert request.page_size == 50 + + +class TestQueryRecords: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + response = client.query_records(request) + + assert response.is_successful + assert isinstance(response.data, GetRecordsResponse) + assert len(response.data.records) == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(400)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error( + client.query_records(request), 400, "Query request is invalid/size of the data requested was too large." + ) + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(401)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error(client.query_records(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + response = client.query_records(request) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(418)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test") + + _assert_error(client.query_records(request), 418, None) + + def test_request_body_excludes_page_params(self, client: OnspringClient): + with respx.mock: + route = respx.post(f"{TEST_URL}/Records/Query").mock(return_value=Response(200, json=MOCK_RECORDS_RESPONSE)) + + from onspring_api_sdk.models import QueryRecordsRequest + + request = QueryRecordsRequest(app_id=100, filter="Test", page_number=2, page_size=10) + client.query_records(request) + + body = route.calls[0].request.content + assert b"pageNumber" not in body + assert b"pageSize" not in body + + +class TestAddOrUpdateRecord: + def _make_record(self) -> Record: + from onspring_api_sdk.models import StringFieldValue + + return Record( + appId=100, + fieldData=[StringFieldValue(fieldId=1, value="test")], + ) + + def test_200_update(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + + response = client.add_or_update_record(self._make_record()) + + assert response.is_successful + assert response.status_code == 200 + assert response.message == "Record updated successfully" + assert isinstance(response.data, AddOrUpdateRecordResponse) + assert response.data.id == 1 + + def test_201_create(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(201, json=MOCK_SAVE_RECORD_RESPONSE)) + + response = client.add_or_update_record(self._make_record()) + + assert response.is_successful + assert response.status_code == 201 + assert response.message == "Record created successfully" + assert isinstance(response.data, AddOrUpdateRecordResponse) + assert response.data.id == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(400)) + + _assert_error(client.add_or_update_record(self._make_record()), 400, "Request data is invalid") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(401)) + + _assert_error(client.add_or_update_record(self._make_record()), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = client.add_or_update_record(self._make_record()) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(418)) + + _assert_error(client.add_or_update_record(self._make_record()), 418, None) + + def test_with_guid_field(self, client: OnspringClient): + import uuid + + from onspring_api_sdk.models import GuidFieldValue, Record + + record = Record( + appId=100, + fieldData=[GuidFieldValue(fieldId=1, value=uuid.UUID("12345678-1234-5678-1234-567812345678"))], + ) + + with respx.mock: + respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + response = client.add_or_update_record(record) + + assert response.is_successful + + def test_payload_excludes_field_data(self, client: OnspringClient): + from onspring_api_sdk.models import Record, StringFieldValue + + record = Record( + appId=100, + fieldData=[StringFieldValue(fieldId=1, value="test")], + ) + + with respx.mock: + route = respx.put(f"{TEST_URL}/Records").mock(return_value=Response(200, json=MOCK_SAVE_RECORD_RESPONSE)) + client.add_or_update_record(record) + + body = route.calls[0].request.content + assert b"fieldData" not in body + + +class TestDeleteRecordsByIds: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(204)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1, 2]) + + response = client.delete_records_by_ids(request) + + assert response.is_successful + assert response.status_code == 204 + assert response.message == "Record(s) deleted successfully" + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(400)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.delete_records_by_ids(request), 400, "Invalid request provided") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(401)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.delete_records_by_ids(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + response = client.delete_records_by_ids(request) + + _assert_error(response, 403, "An error occurred") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(404)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.delete_records_by_ids(request), 404, "Records could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.post(f"{TEST_URL}/Records/batch-delete").mock(return_value=Response(418)) + + from onspring_api_sdk.models import DeleteBatchRecordsRequest + + request = DeleteBatchRecordsRequest(app_id=100, recordIds=[1]) + + _assert_error(client.delete_records_by_ids(request), 418, None) + + +class TestGetReportById: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(200, json=MOCK_REPORT_RESPONSE)) + + request = GetReportByIdRequest(report_id=53) + + response = client.get_report_by_id(request) + + assert response.is_successful + assert isinstance(response.data, GetReportByIdResponse) + assert len(response.data.columns) == 2 + assert len(response.data.rows) == 1 + assert response.data.rows[0].record_id == 1 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(400)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(client.get_report_by_id(request), 400, "Invalid request based on underlying data") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(401)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(client.get_report_by_id(request), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + request = GetReportByIdRequest(report_id=53) + + response = client.get_report_by_id(request) + + _assert_error(response, 403, "An error occurred") + + def test_404(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/999").mock(return_value=Response(404)) + + request = GetReportByIdRequest(report_id=999) + + _assert_error(client.get_report_by_id(request), 404, "Report could not be found") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(418)) + + request = GetReportByIdRequest(report_id=53) + + _assert_error(client.get_report_by_id(request), 418, None) + + def test_params_excludes_report_id(self, client: OnspringClient): + request = GetReportByIdRequest(report_id=53) + + with respx.mock: + route = respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(200, json=MOCK_REPORT_RESPONSE)) + client.get_report_by_id(request) + + assert "reportId" not in route.calls[0].request.url.params + + def test_403_empty_body(self, client: OnspringClient): + request = GetReportByIdRequest(report_id=53) + + with respx.mock: + respx.get(f"{TEST_URL}/Reports/id/53").mock(return_value=Response(403, content=b"")) + response = client.get_report_by_id(request) + + assert response.status_code == 403 + assert response.is_successful is False + + +class TestGetReportsByAppId: + def test_success(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock( + return_value=Response(200, json=MOCK_REPORTS_BY_APP_RESPONSE) + ) + + response = client.get_reports_by_app_id(10) + + assert response.is_successful + assert isinstance(response.data, GetReportsByAppIdResponse) + assert len(response.data.reports) == 1 + assert response.data.reports[0].id == 53 + + def test_400(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(400)) + + _assert_error(client.get_reports_by_app_id(10), 400, "Client does not have read access to the app.") + + def test_401(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(401)) + + _assert_error(client.get_reports_by_app_id(10), 401, "Unauthorized request") + + def test_403_with_message(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(403, json=MOCK_MESSAGE_RESPONSE)) + + response = client.get_reports_by_app_id(10) + + _assert_error(response, 403, "An error occurred") + + def test_fallthrough(self, client: OnspringClient): + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock(return_value=Response(418)) + + _assert_error(client.get_reports_by_app_id(10), 418, None) + + def test_with_explicit_paging(self, client: OnspringClient): + from onspring_api_sdk.models import PagingRequest + + with respx.mock: + respx.get(f"{TEST_URL}/Reports/appId/10").mock( + return_value=Response(200, json=MOCK_REPORTS_BY_APP_RESPONSE) + ) + + paging = PagingRequest(page_number=2, page_size=10) + response = client.get_reports_by_app_id(10, paging_request=paging) + + assert response.is_successful + assert isinstance(response.data, GetReportsByAppIdResponse) + + +class TestRaiseForStatus: + def test_401_raises_authentication_error(self): + response = ApiResponse(status_code=401, message="Unauthorized") + + with pytest.raises(OnspringAuthenticationError, match="Unauthorized"): + response.raise_for_status() + + def test_403_raises_authentication_error(self): + response = ApiResponse(status_code=403, message="Forbidden") + + with pytest.raises(OnspringAuthenticationError, match="Forbidden"): + response.raise_for_status() + + def test_404_raises_not_found_error(self): + response = ApiResponse(status_code=404, message="Not Found") + + with pytest.raises(OnspringNotFoundError, match="Not Found"): + response.raise_for_status() + + def test_429_raises_rate_limit_error(self): + response = ApiResponse(status_code=429, message="Rate limited") + + with pytest.raises(OnspringRateLimitError, match="Rate limited"): + response.raise_for_status() + + def test_418_raises_generic_exception(self): + response = ApiResponse(status_code=418, message="Teapot") + + with pytest.raises(OnspringError, match="Teapot"): + response.raise_for_status() + + def test_success_does_not_raise(self): + response = ApiResponse(status_code=200, data="ok") + + response.raise_for_status() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9149dff --- /dev/null +++ b/uv.lock @@ -0,0 +1,598 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "onspringapisdk" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-rerunfailures" }, + { name = "python-dotenv" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.24.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-rerunfailures", specifier = ">=14.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "respx", specifier = ">=0.20.0" }, + { name = "ruff", specifier = ">=0.1.0" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f0/74f8e685be7ecd1572c1256132f18fce3a665d7e07649a3f23b7eb2d3bec/pytest_rerunfailures-16.3.tar.gz", hash = "sha256:37c9b1231c8083e9f4e724f50f7a21241822f9516c15c700ebbf218d6452355c", size = 34148, upload-time = "2026-05-22T06:51:22.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/98/58a71d68d3126d7f6a6ed1944c37ec207a4ff3dc66cad3bed7b59d38df61/pytest_rerunfailures-16.3-py3-none-any.whl", hash = "sha256:6bdfb8ffb46c46072e6c16bdedee38b6c13eac620d9415ed5b63152cbf283170", size = 15396, upload-time = "2026-05-22T06:51:20.547Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]