22 Commits
Author SHA1 Message Date
Stevan Freeborn 29aa1fc1f6 Merge pull request #10 from StevanFreeborn/stevanfreeborn/feat/rewrite
feat: rewrite for version 3.0
2026-06-26 17:47:19 -05:00
Stevan Freeborn 1f43ca56cc chore: add cSpell configuration for custom words in VSCode settings 2026-06-26 17:43:55 -05:00
Stevan Freeborn 2f737bd722 docs: update README with new SDK API documentation
- Rewrite all code examples for snake_case/Pydantic v2 API
- Add AsyncOnspringClient documentation with async/await examples
- Add error handling documentation with raise_for_status() examples
- Update property names (statusCode -> status_code, etc.)
- Update import paths (OnspringApiSdk -> onspring_api_sdk)
- Update constructor parameter names (baseUrl -> url, apiKey -> key)
- Add httpx as the underlying HTTP library
2026-06-26 17:43:30 -05:00
Stevan Freeborn 208ad9114b ci: add GitHub Actions workflows for PR checks and PyPI publishing
- pull_request.yml: lint (ruff) + test on PRs to master
- publish.yml: build and publish to PyPI on pushes to master
  (uses Trusted Publishing via PyPI publisher)
2026-06-26 17:43:19 -05:00
Stevan Freeborn 6774f42c2f test: add unit and integration test suite for the new SDK
- 268 unit tests covering all sync and async client methods
- 196 integration tests against a real sandbox API
- Mock-based tests using respx for sync and async HTTP mocking
- Paging, error handling, and edge case coverage (400/401/403/404/500/418)
- Integration test utilities and test data files
- .env.example with sandbox environment variables
2026-06-26 17:43:08 -05:00
Stevan Freeborn b976959c30 feat: rewrite SDK from camelCase/requests to snake_case/httpx/Pydantic v2
- Replace OnspringApiSdk package with onspring_api_sdk package
- Add sync (OnspringClient) and async (AsyncOnspringClient) clients
- Migrate from requests to httpx for HTTP transport
- Migrate from raw dataclasses to Pydantic v2 models with alias support
- Add shared response handler module (_responses.py) to reduce duplication
- Add typed exceptions (OnspringError, OnspringAuthenticationError, etc.)
- Preserve full API coverage for apps, fields, files, lists, records, and reports
2026-06-26 17:42:56 -05:00
Stevan Freeborn bc0aa9296b chore: migrate build system from setuptools to hatchling
- Replace setup.cfg with pyproject.toml using hatchling build backend
- Switch from requests to httpx + pydantic v2 dependencies
- Add uv.lock for deterministic dependency resolution
- Add pytest + ruff dev dependencies with configurations
- Update .gitignore for .ruff_cache/
- Bump LICENSE year to 2026
2026-06-26 17:42:36 -05:00
Stevan Freeborn 2e4c1eb560 docs: update save file example 2024-12-19 10:48:25 -06:00
Stevan Freeborn 2a79d3e172 chore: update year in license 2024-12-18 18:01:34 -06:00
Stevan Freeborn fb183839b8 chore: bump version 2024-12-18 18:00:50 -06:00
Stevan Freeborn df795316e6 Merge pull request #7 from StevanFreeborn/stevanfreeborn/fix/stop-mutating-client-headers
fix: stop mutating client headers
2024-12-18 18:00:01 -06:00
Stevan Freeborn ce787860f4 fix: use a copy of the client headers when setting content-type on specific method calls 2024-12-18 17:58:11 -06:00
Stevan Freeborn 627d21154f chore: fix setup.cfg 2024-01-23 17:02:46 -06:00
Stevan Freeborn c68afc962e chore: bump version 2024-01-23 16:35:38 -06:00
Stevan Freeborn 881ef27d04 Merge pull request #3 from StevanFreeborn/stevanfreeborn/fix/address-list-values-not-being-included-in-some-field-responses
fix: address list values not being included in some field responses
2024-01-23 16:31:48 -06:00
Stevan Freeborn 2c736be81a chore: small formatting fix 2024-01-23 16:25:23 -06:00
Stevan Freeborn 355e19708a fix: correct listValues possibly being unbound 2024-01-23 16:24:44 -06:00
Stevan Freeborn b168b599dc fix: update GetFieldsByAppId method to return properly structured fields if field is list or formula type 2024-01-23 16:12:37 -06:00
Stevan Freeborn 8135ccaf5d tests: add failing test for GetFieldsByAppId 2024-01-23 16:11:49 -06:00
Stevan Freeborn 8f035f7308 tests: add additional assertions to test 2024-01-23 16:01:08 -06:00
Stevan Freeborn f343de2b62 tests: add failing test for GetFieldsByIds 2024-01-23 15:50:15 -06:00
Stevan Freeborn 90eb252ae6 fix: updated version number and minimum required version of python 2024-01-23 14:30:44 -06:00
47 changed files with 9125 additions and 3565 deletions
+42
View File
@@ -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
+20
View File
@@ -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
+40
View File
@@ -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
+3
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"cSpell.words": [
"autouse",
"pytestmark",
"respx"
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 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
+340 -296
View File
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -1,3 +1,58 @@
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
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"]
-25
View File
@@ -1,25 +0,0 @@
[metadata]
name = OnspringApiSdk
version = 2.0.0
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
-323
View File
@@ -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}'
-54
View File
@@ -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'
-13
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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",
]
+734
View File
@@ -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)
+368
View File
@@ -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)
+359
View File
@@ -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)
+111
View File
@@ -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}"
+54
View File
@@ -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"
+32
View File
@@ -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."""
+106
View File
@@ -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",
]
+38
View File
@@ -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")
+55
View File
@@ -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}")
+63
View File
@@ -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")
+65
View File
@@ -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
+24
View File
@@ -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
+252
View File
@@ -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)
+56
View File
@@ -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")
+176
View File
@@ -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
View File
+188
View File
@@ -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)
+243
View File
@@ -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
+284
View File
@@ -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
+679
View File
@@ -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
+277
View File
@@ -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
+15
View File
@@ -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
+804
View File
@@ -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
+278
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
This is a test attachment.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File
+33
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Generated
+598
View File
@@ -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" },
]