├── cloudkv ├── py.typed ├── __init__.py ├── __main__.py ├── shared.py ├── _utils.py ├── sync_client.py └── async_client.py ├── tests ├── __init__.py ├── conftest.py ├── test_utils.py ├── test_sync.py └── test_async.py ├── .python-version ├── cf-worker ├── .prettierignore ├── test │ ├── env.d.ts │ ├── tsconfig.json │ └── index.spec.ts ├── vitest.config.mts ├── schema.sql ├── wrangler.jsonc ├── worker-configuration.d.ts ├── package.json ├── tsconfig.json └── src │ └── index.ts ├── LICENSE ├── .pre-commit-config.yaml ├── Makefile ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── pyproject.toml ├── README.md └── uv.lock /cloudkv/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /cf-worker/.prettierignore: -------------------------------------------------------------------------------- 1 | .wrangler/ 2 | -------------------------------------------------------------------------------- /cf-worker/test/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cloudflare:test' { 2 | interface ProvidedEnv extends Env {} 3 | } 4 | -------------------------------------------------------------------------------- /cf-worker/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@cloudflare/vitest-pool-workers"] 5 | }, 6 | "include": ["./**/*.ts", "../worker-configuration.d.ts"], 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /cloudkv/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version as _metadata_version 2 | 3 | from .async_client import AsyncCloudKV 4 | from .sync_client import SyncCloudKV 5 | 6 | __all__ = '__version__', 'SyncCloudKV', 'AsyncCloudKV' 7 | __version__ = _metadata_version('cloudkv') 8 | -------------------------------------------------------------------------------- /cf-worker/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | // from https://github.com/cloudflare/workers-sdk/issues/6581#issuecomment-2653472683 6 | deps: { 7 | optimizer: { 8 | ssr: { 9 | enabled: true, 10 | include: ['@pydantic/logfire-cf-workers'], 11 | }, 12 | }, 13 | }, 14 | poolOptions: { 15 | workers: { 16 | wrangler: { 17 | configPath: './wrangler.jsonc', 18 | }, 19 | miniflare: { 20 | bindings: { 21 | NAMESPACE_SIZE_LIMIT: 10, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /cf-worker/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS namespaces ( 2 | read_token TEXT NOT NULL PRIMARY KEY, 3 | write_token TEXT NOT NULL, 4 | ip TEXT NOT NULL, 5 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | 8 | CREATE INDEX IF NOT EXISTS idx_namespaces_created_at ON namespaces (created_at DESC); 9 | 10 | CREATE TABLE IF NOT EXISTS kv ( 11 | namespace TEXT NOT NULL REFERENCES namespaces (read_token) ON DELETE CASCADE, 12 | key TEXT NOT NULL, 13 | content_type TEXT, -- nullable 14 | size INTEGER NOT NULL, 15 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | expiration TIMESTAMP NOT NULL, 17 | UNIQUE (namespace, key) 18 | ); 19 | 20 | CREATE INDEX IF NOT EXISTS idx_kv_namespace ON kv (namespace); 21 | 22 | CREATE INDEX IF NOT EXISTS idx_kv_expiration ON kv (expiration DESC); 23 | -------------------------------------------------------------------------------- /cf-worker/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "cloudkv", 4 | "main": "src/index.ts", 5 | "compatibility_date": "2025-05-28", 6 | "compatibility_flags": ["nodejs_compat"], 7 | "kv_namespaces": [ 8 | { 9 | "binding": "cloudkvData", 10 | "id": "65c0dc619af8484da481c31e080b528f", 11 | }, 12 | ], 13 | "vars": { 14 | "GITHUB_SHA": "[unknown]", 15 | // if you set `LOGFIRE_TOKEN=pylf...` in .dev.vars, also set `LOGFIRE_ENVIRONMENT=dev` 16 | "LOGFIRE_ENVIRONMENT": "prod", 17 | // 200MB (200 * 1024 * 1024) 18 | "NAMESPACE_SIZE_LIMIT": 209715200, 19 | }, 20 | "d1_databases": [ 21 | { 22 | "binding": "DB", 23 | "database_name": "cloudkv-limits", 24 | "database_id": "d5053851-d744-4295-b41d-2f13ba1e8e3b", 25 | }, 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /cf-worker/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Generated by Wrangler by running `wrangler types --strict-vars=false --include-runtime false` (hash: 88c3fee1951ec1902bc794186bdab2dc) 3 | declare namespace Cloudflare { 4 | interface Env { 5 | cloudkvData: KVNamespace 6 | GITHUB_SHA: string 7 | NAMESPACE_SIZE_LIMIT: number 8 | LOGFIRE_TOKEN: string 9 | LOGFIRE_ENVIRONMENT: string 10 | DB: D1Database 11 | } 12 | } 13 | interface Env extends Cloudflare.Env {} 14 | type StringifyValues> = { 15 | [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string 16 | } 17 | declare namespace NodeJS { 18 | interface ProcessEnv 19 | extends StringifyValues< 20 | Pick 21 | > {} 22 | } 23 | -------------------------------------------------------------------------------- /cf-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudkv", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "format": "prettier --write -- .", 7 | "lint": "prettier --check -- .", 8 | "typecheck": "tsc --noEmit && cd test && tsc --noEmit", 9 | "deploy": "wrangler deploy", 10 | "dev": "wrangler dev", 11 | "start": "wrangler dev", 12 | "test": "vitest", 13 | "typegen": "wrangler types --strict-vars false --include-runtime false && prettier worker-configuration.d.ts --write" 14 | }, 15 | "prettier": { 16 | "singleQuote": true, 17 | "semi": false, 18 | "trailingComma": "all", 19 | "tabWidth": 2, 20 | "printWidth": 119, 21 | "bracketSpacing": true 22 | }, 23 | "devDependencies": { 24 | "@cloudflare/vitest-pool-workers": "^0.8.19", 25 | "@cloudflare/workers-types": "^4.20250613.0", 26 | "prettier": "^3.5.3", 27 | "typescript": "^5.5.2", 28 | "vitest": "~3.0.7", 29 | "wrangler": "^4.17.0" 30 | }, 31 | "dependencies": { 32 | "@pydantic/logfire-api": "^0.4.2", 33 | "@pydantic/logfire-cf-workers": "^0.4.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 to present Samuel Colvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cloudkv/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import sys 5 | 6 | from cloudkv import SyncCloudKV, __version__, shared 7 | 8 | 9 | def cli() -> int: 10 | parser = argparse.ArgumentParser( 11 | prog='cloudkv', 12 | description=f"""\ 13 | CloudKV v{__version__}\n\n 14 | 15 | CLI for creating CloudKV namespces. 16 | 17 | See https://github.com/samuelcolvin/cloudkv for more details. 18 | """, 19 | formatter_class=argparse.RawTextHelpFormatter, 20 | ) 21 | parser.add_argument( 22 | '-u', 23 | '--base-url', 24 | nargs='?', 25 | help=f'CloudKV Base URL, defaults to {shared.DEFAULT_BASE_URL}.', 26 | default=shared.DEFAULT_BASE_URL, 27 | ) 28 | parser.add_argument('--version', action='store_true', help='Show version and exit') 29 | args = parser.parse_args() 30 | if args.version: 31 | print(__version__) 32 | return 0 33 | 34 | print('creating namespace...') 35 | ns = SyncCloudKV.create_namespace(base_url=args.base_url) 36 | 37 | print(f"""\ 38 | Namespace created successfully. 39 | 40 | cloudkv_read_token = {ns.read_token!r} 41 | cloudkv_write_token = {ns.write_token!r}\ 42 | """) 43 | if args.base_url != shared.DEFAULT_BASE_URL: 44 | print(f'cloudkv_base_url = {args.base_url!r}') 45 | return 0 46 | 47 | 48 | def cli_exit(): 49 | sys.exit(cli()) 50 | 51 | 52 | if __name__ == '__main__': 53 | cli() 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: no-commit-to-branch # prevent direct commits to the `main` branch 6 | - id: check-yaml 7 | - id: check-toml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | 11 | - repo: local 12 | hooks: 13 | - id: format-py 14 | name: Format Python 15 | entry: make 16 | args: [format-py] 17 | language: system 18 | types: [python] 19 | pass_filenames: false 20 | - id: typecheck-py 21 | name: Typecheck Python 22 | entry: make 23 | args: [typecheck-py] 24 | language: system 25 | types: [python] 26 | pass_filenames: false 27 | - id: format-ts 28 | name: Format TypeScript 29 | entry: make 30 | args: [format-ts] 31 | language: system 32 | types_or: [javascript, ts, json] 33 | files: "^cf-worker/" 34 | pass_filenames: false 35 | - id: typecheck-ts 36 | name: Typecheck TypeScript 37 | entry: make 38 | args: [typecheck-ts] 39 | language: system 40 | types_or: [javascript, ts, json] 41 | files: "^cf-worker/" 42 | pass_filenames: false 43 | 44 | - repo: https://github.com/codespell-project/codespell 45 | # Configuration for codespell is in pyproject.toml 46 | rev: v2.3.0 47 | hooks: 48 | - id: codespell 49 | additional_dependencies: 50 | - tomli 51 | -------------------------------------------------------------------------------- /cf-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "Bundler", 16 | /* Enable importing .json files */ 17 | "resolveJsonModule": true, 18 | 19 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 20 | "allowJs": true, 21 | /* Enable error reporting in type-checked JavaScript files. */ 22 | "checkJs": false, 23 | 24 | /* Disable emitting files from a compilation. */ 25 | "noEmit": true, 26 | 27 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 28 | "isolatedModules": true, 29 | /* Allow 'import x from y' when a module doesn't have a default export. */ 30 | "allowSyntheticDefaultImports": true, 31 | /* Ensure that casing is correct in imports. */ 32 | "forceConsistentCasingInFileNames": true, 33 | 34 | /* Enable all strict type-checking options. */ 35 | "strict": true, 36 | 37 | /* Skip type checking all .d.ts files. */ 38 | "skipLibCheck": true, 39 | "types": ["@cloudflare/workers-types", "./worker-configuration.d.ts"] 40 | }, 41 | "exclude": ["test"], 42 | "include": ["worker-configuration.d.ts", "src/**/*.ts"] 43 | } 44 | -------------------------------------------------------------------------------- /cloudkv/shared.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import os 4 | import typing 5 | from datetime import datetime 6 | 7 | import httpx 8 | import pydantic 9 | 10 | if typing.TYPE_CHECKING: 11 | from . import AsyncCloudKV, SyncCloudKV 12 | 13 | __all__ = ( 14 | 'DEFAULT_BASE_URL', 15 | 'PYDANTIC_CONTENT_TYPE', 16 | 'CreateNamespaceDetails', 17 | 'KeyInfo', 18 | 'KeysResponse', 19 | 'ResponseError', 20 | ) 21 | DEFAULT_BASE_URL = os.getenv('CLOUDKV_BASE_URL', 'https://cloudkv.samuelcolvin.workers.dev') 22 | PYDANTIC_CONTENT_TYPE = 'application/json; pydantic' 23 | 24 | 25 | class CreateNamespaceDetails(pydantic.BaseModel): 26 | base_url: str 27 | """Base URL of the namespace""" 28 | read_token: str 29 | """Read API key for the namespace""" 30 | write_token: str 31 | """Write API key for the namespace""" 32 | created_at: datetime 33 | """Creation timestamp of the namespace""" 34 | 35 | def sync_client(self) -> SyncCloudKV: 36 | from .sync_client import SyncCloudKV 37 | 38 | return SyncCloudKV(self.read_token, self.write_token, base_url=self.base_url) 39 | 40 | def async_client(self) -> AsyncCloudKV: 41 | from .async_client import AsyncCloudKV 42 | 43 | return AsyncCloudKV(self.read_token, self.write_token, base_url=self.base_url) 44 | 45 | 46 | class KeyInfo(pydantic.BaseModel): 47 | url: str 48 | """URL of the key/value""" 49 | key: str 50 | """The key""" 51 | content_type: str | None 52 | """Content type set in the datastore""" 53 | size: int 54 | """Size of the value in bytes""" 55 | created_at: datetime 56 | """Creation timestamp of the key/value""" 57 | expiration: datetime 58 | """Expiration timestamp of the key/value""" 59 | 60 | 61 | class KeysResponse(pydantic.BaseModel): 62 | keys: list[KeyInfo] 63 | 64 | 65 | class ResponseError(ValueError): 66 | @classmethod 67 | def check(cls, response: httpx.Response) -> None: 68 | if not response.is_success: 69 | raise cls(f'Unexpected {response.status_code} response: {response.text}') 70 | -------------------------------------------------------------------------------- /cloudkv/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import typing 4 | 5 | import pydantic 6 | 7 | from .shared import PYDANTIC_CONTENT_TYPE 8 | 9 | T = typing.TypeVar('T') 10 | D = typing.TypeVar('D') 11 | ta_lookup: dict[str, pydantic.TypeAdapter[typing.Any]] = {} 12 | 13 | 14 | def cached_type_adapter(return_type: type[T]) -> pydantic.TypeAdapter[T]: 15 | key = return_type.__qualname__ 16 | if ta := ta_lookup.get(key): 17 | return ta 18 | else: 19 | ta_lookup[key] = ta = pydantic.TypeAdapter(return_type) 20 | return ta 21 | 22 | 23 | def encode_value(value: typing.Any) -> tuple[bytes, str | None]: 24 | if isinstance(value, str): 25 | return value.encode('utf-8'), 'text/plain' 26 | elif isinstance(value, bytes): 27 | return value, None 28 | elif isinstance(value, bytearray): 29 | return bytes(value), None 30 | else: 31 | value_type: type[typing.Any] = type(value) 32 | return cached_type_adapter(value_type).dump_json(value), PYDANTIC_CONTENT_TYPE 33 | 34 | 35 | def decode_value( 36 | data: bytes | None, content_type: str | None, return_type: type[T], default: D, force_validate: bool 37 | ) -> T | D: 38 | if data is None: 39 | return default 40 | elif force_validate or content_type == PYDANTIC_CONTENT_TYPE: 41 | return cached_type_adapter(return_type).validate_json(data) 42 | elif return_type is bytes: 43 | return typing.cast(T, data) 44 | elif return_type is str: 45 | return typing.cast(T, data.decode()) 46 | elif return_type is bytearray: 47 | return typing.cast(T, bytearray(data)) 48 | else: 49 | raise RuntimeError(f'Content-Type was not {PYDANTIC_CONTENT_TYPE!r} and return_type was not a string type') 50 | 51 | 52 | def keys_query_params( 53 | starts_with: str | None, ends_with: str | None, contains: str | None, like: str | None, offset: int | None 54 | ) -> dict[str, str]: 55 | if starts_with is not None: 56 | like = _escape_like_pattern(starts_with) + '%' 57 | elif ends_with is not None: 58 | like = '%' + _escape_like_pattern(ends_with) 59 | elif contains is not None: 60 | like = '%' + _escape_like_pattern(contains) + '%' 61 | 62 | params = {'like': like} if like is not None else {} 63 | if offset is not None: 64 | params['offset'] = str(offset) 65 | 66 | return params 67 | 68 | 69 | def _escape_like_pattern(pattern: str) -> str: 70 | return pattern.replace('%', '\\%').replace('_', '\\_') 71 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | import time 5 | from datetime import datetime, timezone 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING, Any, Iterable 8 | 9 | import httpx 10 | import pytest 11 | 12 | if TYPE_CHECKING: 13 | 14 | def IsDatetime(*args: Any, **kwargs: Any) -> datetime: ... 15 | def IsFloat(*args: Any, **kwargs: Any) -> float: ... 16 | def IsInt(*args: Any, **kwargs: Any) -> int: ... 17 | def IsNow(*args: Any, **kwargs: Any) -> datetime: ... 18 | def IsStr(*args: Any, **kwargs: Any) -> str: ... 19 | else: 20 | from dirty_equals import IsDatetime, IsFloat, IsInt, IsNow as _IsNow, IsStr 21 | 22 | def IsNow(*args: Any, **kwargs: Any): 23 | kwargs.setdefault('delta', 10) 24 | kwargs.setdefault('tz', timezone.utc) 25 | return _IsNow(*args, **kwargs) 26 | 27 | 28 | @pytest.fixture(scope='session') 29 | def anyio_backend(): 30 | return 'asyncio' 31 | 32 | 33 | @pytest.fixture(scope='session') 34 | def server() -> Iterable[str]: 35 | """Run the dev cf worker.""" 36 | if remove_url := os.getenv('TEST_AGAINST_REMOTE'): 37 | yield remove_url # pragma: no cover 38 | else: 39 | base_url = 'http://localhost:8787' 40 | cf_dir = Path(__file__).parent.parent / 'cf-worker' 41 | schema_sql = (cf_dir / 'schema.sql').read_text() 42 | schema_sql += '\ndelete from namespaces;' 43 | 44 | with tempfile.NamedTemporaryFile() as f: 45 | f.write(schema_sql.encode()) 46 | f.flush() 47 | # reset the local database for testing 48 | p = subprocess.run( 49 | ['npx', 'wrangler', 'd1', 'execute', 'cloudkv-limits', '--local', '--file', f.name], 50 | cwd=cf_dir, 51 | stdout=subprocess.PIPE, 52 | stderr=subprocess.STDOUT, 53 | ) 54 | if p.returncode != 0: # pragma: no cover 55 | raise RuntimeError(f'SQL reset command failed with exit code {p.returncode}:\n{p.stdout.decode()}') 56 | 57 | server_process = subprocess.Popen( 58 | ['npm', 'run', 'dev'], 59 | cwd=cf_dir, 60 | stdout=subprocess.PIPE, 61 | stderr=subprocess.STDOUT, 62 | ) 63 | try: 64 | _check_connection(base_url) 65 | 66 | yield base_url 67 | 68 | finally: 69 | # Stop the development server 70 | server_process.terminate() 71 | 72 | 73 | def _check_connection(base_url: str): # pragma: no cover 74 | with httpx.Client(timeout=1) as client: 75 | for _ in range(10): 76 | try: 77 | r = client.get(base_url) 78 | except httpx.HTTPError: 79 | time.sleep(0.1) 80 | else: 81 | if r.status_code == 200: 82 | break 83 | 84 | r = client.get(base_url) 85 | r.raise_for_status() 86 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | sources = pytest_pretty 3 | 4 | .PHONY: .uv 5 | .uv: ## Check that uv is installed 6 | @uv --version || echo 'Please install uv: https://docs.astral.sh/uv/getting-started/installation/' 7 | 8 | .PHONY: .pre-commit 9 | .pre-commit: ## Check that pre-commit is installed 10 | @pre-commit -V || echo 'Please install pre-commit: https://pre-commit.com/' 11 | 12 | .PHONY: install 13 | install: .uv .pre-commit ## Install the package, dependencies, and pre-commit for local development 14 | uv sync --frozen 15 | pre-commit install --install-hooks 16 | 17 | .PHONY: format-py 18 | format-py: ## Format Python code 19 | uv run ruff format 20 | uv run ruff check --fix --fix-only 21 | 22 | .PHONY: format-ts 23 | format-ts: ## Format TS and JS code 24 | cd cf-worker && npm run format 25 | 26 | .PHONY: format 27 | format: format-py format-ts ## Format all code 28 | 29 | .PHONY: lint-py 30 | lint-py: ## Lint Python code 31 | uv run ruff format --check 32 | uv run ruff check 33 | 34 | .PHONY: lint-ts 35 | lint-ts: ## Lint TS and JS code 36 | cd cf-worker && npm run lint 37 | 38 | .PHONY: lint 39 | lint: lint-py lint-ts ## Lint all code 40 | 41 | .PHONY: typecheck-py 42 | typecheck-py: ## Typecheck the code 43 | @# PYRIGHT_PYTHON_IGNORE_WARNINGS avoids the overhead of making a request to github on every invocation 44 | PYRIGHT_PYTHON_IGNORE_WARNINGS=1 uv run pyright 45 | 46 | .PHONY: typecheck-ts 47 | typecheck-ts: ## Typecheck TS and JS code 48 | cd cf-worker && npm run typecheck 49 | 50 | .PHONY: typecheck 51 | typecheck: typecheck-py typecheck-ts ## Typecheck all code 52 | 53 | .PHONY: test-py 54 | test-py: ## Run Python tests 55 | uv run coverage run -m pytest 56 | uv run coverage report --fail-under=100 57 | 58 | .PHONY: testcov 59 | testcov: ## Run python tests and generate a coverage report 60 | uv run coverage run -m pytest 61 | @echo "building coverage html" 62 | @uv run coverage html 63 | 64 | .PHONY: test-ts 65 | test-ts: ## Run TS and JS tests 66 | cd cf-worker && CI=1 npm run test 67 | 68 | .PHONY: test 69 | test: test-py test-ts ## Run all tests 70 | 71 | .PHONY: test-all-python 72 | test-all-python: ## Run tests on Python 3.9 to 3.13 73 | UV_PROJECT_ENVIRONMENT=.venv39 uv run --python 3.9 coverage run -p -m pytest 74 | UV_PROJECT_ENVIRONMENT=.venv310 uv run --python 3.10 coverage run -p -m pytest 75 | UV_PROJECT_ENVIRONMENT=.venv311 uv run --python 3.11 coverage run -p -m pytest 76 | UV_PROJECT_ENVIRONMENT=.venv312 uv run --python 3.12 coverage run -p -m pytest 77 | UV_PROJECT_ENVIRONMENT=.venv313 uv run --python 3.13 coverage run -p -m pytest 78 | @uv run coverage combine 79 | @uv run coverage report 80 | 81 | .PHONY: all 82 | all: format typecheck test ## run format, typecheck and test 83 | 84 | .PHONY: help 85 | help: ## Show this help (usage: make help) 86 | @echo "Usage: make [recipe]" 87 | @echo "Recipes:" 88 | @awk '/^[a-zA-Z0-9_-]+:.*?##/ { \ 89 | helpMessage = match($$0, /## (.*)/); \ 90 | if (helpMessage) { \ 91 | recipe = $$1; \ 92 | sub(/:/, "", recipe); \ 93 | printf " \033[36m%-20s\033[0m %s\n", recipe, substr($$0, RSTART + 3, RLENGTH); \ 94 | } \ 95 | }' $(MAKEFILE_LIST) 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python 2 | *.py[cod] 3 | __pycache__ 4 | .ruff_cache 5 | .venv 6 | 7 | main.py 8 | example.py 9 | .coverage* 10 | 11 | # Logs 12 | 13 | logs 14 | _.log 15 | npm-debug.log_ 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | .pnpm-debug.log* 20 | 21 | # Diagnostic reports (https://nodejs.org/api/report.html) 22 | 23 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 24 | 25 | # Runtime data 26 | 27 | pids 28 | _.pid 29 | _.seed 30 | \*.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | 38 | coverage 39 | \*.lcov 40 | 41 | # nyc test coverage 42 | 43 | .nyc_output 44 | 45 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 46 | 47 | .grunt 48 | 49 | # Bower dependency directory (https://bower.io/) 50 | 51 | bower_components 52 | 53 | # node-waf configuration 54 | 55 | .lock-wscript 56 | 57 | # Compiled binary addons (https://nodejs.org/api/addons.html) 58 | 59 | build/Release 60 | 61 | # Dependency directories 62 | 63 | node_modules/ 64 | jspm_packages/ 65 | 66 | # Snowpack dependency directory (https://snowpack.dev/) 67 | 68 | web_modules/ 69 | 70 | # TypeScript cache 71 | 72 | \*.tsbuildinfo 73 | 74 | # Optional npm cache directory 75 | 76 | .npm 77 | 78 | # Optional eslint cache 79 | 80 | .eslintcache 81 | 82 | # Optional stylelint cache 83 | 84 | .stylelintcache 85 | 86 | # Microbundle cache 87 | 88 | .rpt2_cache/ 89 | .rts2_cache_cjs/ 90 | .rts2_cache_es/ 91 | .rts2_cache_umd/ 92 | 93 | # Optional REPL history 94 | 95 | .node_repl_history 96 | 97 | # Output of 'npm pack' 98 | 99 | \*.tgz 100 | 101 | # Yarn Integrity file 102 | 103 | .yarn-integrity 104 | 105 | # dotenv environment variable files 106 | 107 | .env 108 | .env.development.local 109 | .env.test.local 110 | .env.production.local 111 | .env.local 112 | 113 | # parcel-bundler cache (https://parceljs.org/) 114 | 115 | .cache 116 | .parcel-cache 117 | 118 | # Next.js build output 119 | 120 | .next 121 | out 122 | 123 | # Nuxt.js build / generate output 124 | 125 | .nuxt 126 | dist 127 | 128 | # Gatsby files 129 | 130 | .cache/ 131 | 132 | # Comment in the public line in if your project uses Gatsby and not Next.js 133 | 134 | # https://nextjs.org/blog/next-9-1#public-directory-support 135 | 136 | # public 137 | 138 | # vuepress build output 139 | 140 | .vuepress/dist 141 | 142 | # vuepress v2.x temp and cache directory 143 | 144 | .temp 145 | .cache 146 | 147 | # Docusaurus cache and generated files 148 | 149 | .docusaurus 150 | 151 | # Serverless directories 152 | 153 | .serverless/ 154 | 155 | # FuseBox cache 156 | 157 | .fusebox/ 158 | 159 | # DynamoDB Local files 160 | 161 | .dynamodb/ 162 | 163 | # TernJS port file 164 | 165 | .tern-port 166 | 167 | # Stores VSCode versions used for testing VSCode extensions 168 | 169 | .vscode-test 170 | 171 | # yarn v2 172 | 173 | .yarn/cache 174 | .yarn/unplugged 175 | .yarn/build-state.yml 176 | .yarn/install-state.gz 177 | .pnp.\* 178 | 179 | # wrangler project 180 | 181 | .dev.vars 182 | .wrangler/ 183 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "**" 9 | pull_request: {} 10 | 11 | env: 12 | COLUMNS: 150 13 | UV_PYTHON: 3.12 14 | UV_FROZEN: "1" 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: astral-sh/setup-uv@v5 23 | with: 24 | enable-cache: true 25 | 26 | - run: uv sync 27 | 28 | - uses: actions/setup-node@v4 29 | 30 | - run: npm i 31 | working-directory: cf-worker 32 | 33 | - uses: pre-commit/action@v3.0.0 34 | with: 35 | extra_args: --all-files --verbose 36 | env: 37 | SKIP: no-commit-to-branch 38 | 39 | test-python: 40 | name: test py${{ matrix.python-version }} 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 46 | 47 | env: 48 | UV_PYTHON: ${{ matrix.python-version }} 49 | 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - uses: astral-sh/setup-uv@v5 55 | with: 56 | enable-cache: true 57 | 58 | - uses: actions/setup-node@v4 59 | 60 | - run: npm i 61 | working-directory: cf-worker 62 | 63 | - run: make test-py 64 | 65 | test-cf-worker: 66 | name: test cf worker 67 | 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - uses: actions/setup-node@v4 73 | 74 | - run: npm i 75 | working-directory: cf-worker 76 | 77 | - run: npm run test 78 | working-directory: cf-worker 79 | 80 | # https://github.com/marketplace/actions/alls-green#why used for branch protection checks 81 | check: 82 | if: always() 83 | needs: [lint, test-python, test-cf-worker] 84 | runs-on: ubuntu-latest 85 | steps: 86 | - name: Decide whether the needed jobs succeeded or failed 87 | uses: re-actors/alls-green@release/v1 88 | with: 89 | jobs: ${{ toJSON(needs) }} 90 | 91 | deploy-worker: 92 | needs: [check] 93 | if: "success() && github.ref == 'refs/heads/main'" 94 | runs-on: ubuntu-latest 95 | environment: cloudflare-workers-deploy 96 | 97 | steps: 98 | - uses: actions/checkout@v4 99 | 100 | - uses: actions/setup-node@v4 101 | 102 | - run: npm i 103 | working-directory: cf-worker 104 | 105 | - uses: cloudflare/wrangler-action@v3 106 | with: 107 | apiToken: ${{ secrets.cloudflare_api_token }} 108 | command: deploy --var GITHUB_SHA:${{ github.sha }} 109 | workingDirectory: cf-worker 110 | 111 | release: 112 | needs: [check] 113 | if: success() && startsWith(github.ref, 'refs/tags/') 114 | runs-on: ubuntu-latest 115 | 116 | environment: 117 | name: release 118 | 119 | permissions: 120 | id-token: write 121 | 122 | steps: 123 | - uses: actions/checkout@v4 124 | 125 | - uses: astral-sh/setup-uv@v5 126 | with: 127 | enable-cache: true 128 | 129 | - name: check GITHUB_REF matches package version 130 | uses: samuelcolvin/check-python-version@v4.1 131 | with: 132 | version_file_path: pyproject.toml 133 | 134 | - run: uv build 135 | 136 | - run: uv publish --trusted-publishing always 137 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "cloudkv" 7 | version = "0.3.0" 8 | description = "Hosted key/value store based on Cloudflare workers and KV store." 9 | authors = [{ name = "Samuel Colvin", email = "s@muelcolvin.com" }] 10 | license = "MIT" 11 | readme = "README.md" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Intended Audience :: Developers", 23 | "Intended Audience :: Information Technology", 24 | "Intended Audience :: System Administrators", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: Unix", 27 | "Operating System :: POSIX :: Linux", 28 | "Environment :: Console", 29 | "Environment :: MacOS X", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | "Topic :: Internet", 32 | ] 33 | 34 | requires-python = ">=3.9" 35 | dependencies = [ 36 | "eval-type-backport>=0.2.2", 37 | "httpx>=0.28.1", 38 | "pydantic>=2.11.5", 39 | ] 40 | 41 | [project.scripts] 42 | cloudkv = "cloudkv.__main__:cli" 43 | 44 | [dependency-groups] 45 | dev = [ 46 | "coverage[toml]>=7.8.2", 47 | "devtools>=0.12.2", 48 | "ruff>=0.11.11", 49 | "pytest>=8.4.0", 50 | "pytest-pretty>=1.3.0", 51 | "pyright>=1.1.398", 52 | "inline-snapshot[black]>=0.23.2", 53 | "dirty-equals>=0.9.0", 54 | "anyio>=4.9.0", 55 | ] 56 | 57 | [tool.pytest.ini_options] 58 | testpaths = "tests" 59 | xfail_strict = true 60 | 61 | [tool.coverage.run] 62 | include = ["cloudkv/**/*.py", "tests/**/*.py"] 63 | branch = true 64 | 65 | [tool.coverage.report] 66 | skip_covered = true 67 | show_missing = true 68 | ignore_errors = true 69 | precision = 2 70 | exclude_lines = [ 71 | 'pragma: no cover', 72 | 'raise NotImplementedError', 73 | 'if TYPE_CHECKING:', 74 | 'if typing.TYPE_CHECKING:', 75 | '@.*overload', 76 | '@deprecated', 77 | '@typing.overload', 78 | '@abstractmethod', 79 | '\(Protocol\):$', 80 | 'typing.assert_never', 81 | '$\s*assert_never\(', 82 | 'if __name__ == .__main__.:', 83 | '$\s*pass$', 84 | ] 85 | 86 | 87 | [tool.ruff] 88 | line-length = 120 89 | target-version = "py39" 90 | include = ["cloudkv/**/*.py"] 91 | 92 | [tool.ruff.lint] 93 | extend-select = ["Q", "RUF100", "C90", "UP", "I"] 94 | flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" } 95 | isort = { combine-as-imports = true } 96 | mccabe = { max-complexity = 15 } 97 | 98 | [tool.ruff.lint.pydocstyle] 99 | convention = "google" 100 | 101 | [tool.ruff.format] 102 | # don't format python in docstrings, pytest-examples takes care of it 103 | docstring-code-format = false 104 | quote-style = "single" 105 | 106 | [tool.pyright] 107 | pythonVersion = "3.9" 108 | typeCheckingMode = "strict" 109 | reportUnnecessaryTypeIgnoreComment = true 110 | include = ["cloudkv", "tests"] 111 | venv = ".venv" 112 | 113 | [tool.codespell] 114 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 115 | skip = 'cf-worker/worker-configuration.d.ts,cf-worker/package-lock.json' 116 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import pydantic 6 | import pytest 7 | from inline_snapshot import snapshot 8 | 9 | from cloudkv import _utils as utils 10 | 11 | 12 | @pytest.mark.parametrize( 13 | 'value,expected_data,expected_content_type', 14 | [ 15 | ('test', snapshot(b'test'), snapshot('text/plain')), 16 | (b'test', snapshot(b'test'), snapshot(None)), 17 | (bytearray(b'test'), snapshot(b'test'), snapshot(None)), 18 | ([1, 2, 3], snapshot(b'[1,2,3]'), snapshot('application/json; pydantic')), 19 | ({'a': 1, 'b': 2}, snapshot(b'{"a":1,"b":2}'), snapshot('application/json; pydantic')), 20 | ({'a': 1, 'b': 2}, snapshot(b'{"a":1,"b":2}'), snapshot('application/json; pydantic')), 21 | ], 22 | ) 23 | def test_encode(value: Any, expected_data: bytes, expected_content_type: str): 24 | data, content_type = utils.encode_value(value) 25 | assert data == expected_data 26 | assert content_type == expected_content_type 27 | 28 | 29 | def decode_value_kwargs( 30 | data: bytes | None, 31 | content_type: str | None, 32 | return_type: type[Any], 33 | default: Any = None, 34 | force_validate: bool = False, 35 | ) -> dict[str, Any]: 36 | return { 37 | 'data': data, 38 | 'content_type': content_type, 39 | 'return_type': return_type, 40 | 'default': default, 41 | 'force_validate': force_validate, 42 | } 43 | 44 | 45 | class Model(pydantic.BaseModel): 46 | x: int 47 | y: list[dict[str, tuple[set[bytes], float]]] 48 | 49 | 50 | @pytest.mark.parametrize( 51 | 'kwargs,expected', 52 | [ 53 | (decode_value_kwargs(None, None, int), snapshot(None)), 54 | (decode_value_kwargs(b'hello', None, str), snapshot('hello')), 55 | (decode_value_kwargs(b'hello', None, bytes), snapshot(b'hello')), 56 | (decode_value_kwargs(b'hello', None, bytearray), snapshot(bytearray(b'hello'))), 57 | (decode_value_kwargs(b'123', 'application/json; pydantic', int), snapshot(123)), 58 | ( 59 | decode_value_kwargs(b'{"x":1,"y":[{"a":[["b","c"],1.0]}]}', 'application/json; pydantic', dict[str, Any]), 60 | snapshot({'x': 1, 'y': [{'a': [['b', 'c'], 1.0]}]}), 61 | ), 62 | ( 63 | decode_value_kwargs(b'{"x":1,"y":[{"a":[["b","c"],1.0]}]}', 'application/json; pydantic', Model), 64 | snapshot(Model(x=1, y=[{'a': ({b'b', b'c'}, 1.0)}])), 65 | ), 66 | ], 67 | ) 68 | def test_decode_value_kwargs(kwargs: dict[str, Any], expected: Any): 69 | assert utils.decode_value(**kwargs) == expected 70 | 71 | 72 | @pytest.mark.parametrize( 73 | 'kwargs', 74 | [ 75 | (decode_value_kwargs(b'123', None, int)), 76 | (decode_value_kwargs(b'[1, 2, 3]', None, list[int])), 77 | (decode_value_kwargs(b'123', None, Model)), 78 | ], 79 | ) 80 | def test_decode_value_kwargs_error(kwargs: dict[str, Any]): 81 | with pytest.raises(RuntimeError, match='Content-Type was not'): 82 | utils.decode_value(**kwargs) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | 'kwargs,params', 87 | [ 88 | ({}, snapshot({})), 89 | ({'like': 'test'}, snapshot({'like': 'test'})), 90 | ({'offset': 10}, snapshot({'offset': '10'})), 91 | ({'starts_with': 'test'}, snapshot({'like': 'test%'})), 92 | ({'starts_with': 'te%st'}, snapshot({'like': 'te\\%st%'})), 93 | ({'ends_with': 'test'}, snapshot({'like': '%test'})), 94 | ({'contains': 'test'}, snapshot({'like': '%test%'})), 95 | ], 96 | ) 97 | def test_keys_query_params(kwargs: dict[str, Any], params: dict[str, str]): 98 | kwargs.setdefault('starts_with', None) 99 | kwargs.setdefault('ends_with', None) 100 | kwargs.setdefault('contains', None) 101 | kwargs.setdefault('like', None) 102 | kwargs.setdefault('offset', None) 103 | assert utils.keys_query_params(**kwargs) == params 104 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from dirty_equals import HasLen, IsList, IsStr 5 | 6 | from cloudkv import SyncCloudKV, shared 7 | 8 | from .conftest import IsDatetime, IsNow 9 | 10 | pytestmark = pytest.mark.anyio 11 | 12 | 13 | def test_init(): 14 | kv = SyncCloudKV('read', 'write', base_url='https://example.com/') 15 | assert kv.namespace_read_token == 'read' 16 | assert kv.namespace_write_token == 'write' 17 | assert kv.base_url == 'https://example.com' 18 | 19 | 20 | def test_create_namespace(server: str): 21 | create_details = SyncCloudKV.create_namespace(base_url=server) 22 | assert create_details.model_dump() == { 23 | 'base_url': server, 24 | 'read_token': IsStr() & HasLen(24), 25 | 'write_token': IsStr() & HasLen(48), 26 | 'created_at': IsNow(), 27 | } 28 | 29 | 30 | def test_get_set(server: str): 31 | kv = SyncCloudKV.create_namespace(base_url=server).sync_client() 32 | 33 | url = kv.set('test_key', 'test_value') 34 | assert url == f'{server}/{kv.namespace_read_token}/test_key' 35 | assert kv.get('test_key') == b'test_value' 36 | 37 | 38 | def test_get_as(server: str): 39 | with SyncCloudKV.create_namespace(base_url=server).sync_client() as kv: 40 | kv.set('list_of_ints', [1, 2, 3]) 41 | assert kv.get_as('list_of_ints', list[int]) == [1, 2, 3] 42 | 43 | 44 | def test_keys(server: str): 45 | kv = SyncCloudKV.create_namespace(base_url=server).sync_client() 46 | 47 | kv.set('test_key', 'test_value') 48 | kv.set('list_of_ints', [1, 2, 3]) 49 | keys = kv.keys() 50 | assert [k.key for k in keys] == IsList('test_key', 'list_of_ints', check_order=False) 51 | 52 | keys = kv.keys(starts_with='test') 53 | assert [k.model_dump() for k in keys] == [ 54 | { 55 | 'url': f'{kv.base_url}/{kv.namespace_read_token}/test_key', 56 | 'key': 'test_key', 57 | 'content_type': 'text/plain', 58 | 'size': 10, 59 | 'created_at': IsNow(), 60 | 'expiration': IsDatetime(), 61 | } 62 | ] 63 | 64 | 65 | def test_delete(server: str): 66 | kv = SyncCloudKV.create_namespace(base_url=server).sync_client() 67 | 68 | kv.set('test_key', b'test_value') 69 | assert kv.get('test_key') == b'test_value' 70 | 71 | keys = kv.keys() 72 | assert [k.key for k in keys] == ['test_key'] 73 | assert [k.content_type for k in keys] == [None] 74 | 75 | kv.delete('test_key') 76 | 77 | assert kv.get('test_key') is None 78 | assert [k.key for k in kv.keys()] == [] 79 | 80 | 81 | def test_read_only(server: str): 82 | kv = SyncCloudKV.create_namespace(base_url=server).sync_client() 83 | 84 | kv.set('test_key', 'test_value') 85 | assert kv.get('test_key') == b'test_value' 86 | 87 | kv_readonly = SyncCloudKV(kv.namespace_read_token, None, base_url=kv.base_url) 88 | assert kv_readonly.get('test_key') == b'test_value' 89 | 90 | with pytest.raises(RuntimeError, match="Namespace write key not provided, can't set"): 91 | kv_readonly.set('test_key', 'test_value') 92 | 93 | with pytest.raises(RuntimeError, match="Namespace write key not provided, can't delete"): 94 | kv_readonly.delete('test_key') 95 | 96 | 97 | def test_expires(server: str): 98 | kv = SyncCloudKV.create_namespace(base_url=server).sync_client() 99 | kv.set('test_key', 'test_value', expires=123) 100 | 101 | keys = kv.keys() 102 | assert len(keys) == 1 103 | 104 | key = keys[0] 105 | assert (key.expiration - key.created_at).total_seconds() == 123 106 | 107 | kv.set('test_key2', 'test_value', expires=timedelta(seconds=42)) 108 | 109 | keys = kv.keys(like='test_key2') 110 | assert len(keys) == 1 111 | key = keys[0] 112 | assert (key.expiration - key.created_at).total_seconds() == 60 113 | 114 | 115 | def test_invalid_tokens(server: str): 116 | kv = SyncCloudKV('0' * 24, 'bar', base_url=server) 117 | 118 | with pytest.raises(shared.ResponseError, match='Unexpected 404 response: Namespace does not exist'): 119 | kv.set('test_key', 'test_value') 120 | -------------------------------------------------------------------------------- /tests/test_async.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from dirty_equals import HasLen, IsStr, IsStrictDict 5 | 6 | from cloudkv import AsyncCloudKV 7 | 8 | from .conftest import IsDatetime, IsNow 9 | 10 | pytestmark = pytest.mark.anyio 11 | 12 | 13 | def test_init(): 14 | kv = AsyncCloudKV('read', 'write', base_url='https://example.com/') 15 | assert kv.namespace_read_token == 'read' 16 | assert kv.namespace_write_token == 'write' 17 | assert kv.base_url == 'https://example.com' 18 | 19 | msg = 'HTTP client not initialized - AsyncCloudKV must be used as an async context manager' 20 | with pytest.raises(RuntimeError, match=msg): 21 | kv.client 22 | 23 | 24 | async def test_create_namespace(server: str): 25 | create_details = await AsyncCloudKV.create_namespace(base_url=server) 26 | assert create_details.model_dump() == IsStrictDict( 27 | base_url=server, 28 | read_token=IsStr() & HasLen(24), 29 | write_token=IsStr() & HasLen(48), 30 | created_at=IsNow(), 31 | ) 32 | 33 | 34 | async def test_get_set_tokens(server: str): 35 | create_details = await AsyncCloudKV.create_namespace(base_url=server) 36 | 37 | async with create_details.async_client() as kv: 38 | url = await kv.set('test_key', 'test_value') 39 | assert url == f'{server}/{create_details.read_token}/test_key' 40 | assert await kv.get('test_key') == b'test_value' 41 | 42 | keys = await kv.keys() 43 | assert [k.model_dump() for k in keys] == [ 44 | { 45 | 'url': f'{server}/{create_details.read_token}/test_key', 46 | 'key': 'test_key', 47 | 'content_type': 'text/plain', 48 | 'size': 10, 49 | 'created_at': IsNow(), 50 | 'expiration': IsDatetime(), 51 | } 52 | ] 53 | 54 | await kv.set('list_of_ints', [1, 2, 3]) 55 | assert await kv.get_as('list_of_ints', list[int]) == [1, 2, 3] 56 | 57 | 58 | async def test_delete(server: str): 59 | create_details = await AsyncCloudKV.create_namespace(base_url=server) 60 | 61 | async with create_details.async_client() as kv: 62 | await kv.set('test_key', b'test_value') 63 | assert await kv.get('test_key') == b'test_value' 64 | keys = await kv.keys() 65 | assert [k.key for k in keys] == ['test_key'] 66 | assert [k.content_type for k in keys] == [None] 67 | 68 | await kv.delete('test_key') 69 | assert await kv.get('test_key') is None 70 | keys = await kv.keys() 71 | assert [k.key for k in keys] == [] 72 | 73 | 74 | async def test_read_only(server: str): 75 | create_details = await AsyncCloudKV.create_namespace(base_url=server) 76 | async with create_details.async_client() as kv: 77 | await kv.set('test_key', 'test_value') 78 | assert await kv.get('test_key') == b'test_value' 79 | 80 | async with AsyncCloudKV(create_details.read_token, None, base_url=server) as kv_readonly: 81 | assert await kv_readonly.get('test_key') == b'test_value' 82 | 83 | with pytest.raises(RuntimeError, match="Namespace write key not provided, can't set"): 84 | await kv_readonly.set('test_key', 'test_value') 85 | 86 | with pytest.raises(RuntimeError, match="Namespace write key not provided, can't delete"): 87 | await kv_readonly.delete('test_key') 88 | 89 | 90 | async def test_expires(server: str): 91 | create_details = await AsyncCloudKV.create_namespace(base_url=server) 92 | 93 | async with create_details.async_client() as kv: 94 | await kv.set('test_key', 'test_value', expires=123) 95 | 96 | keys = await kv.keys() 97 | assert len(keys) == 1 98 | key = keys[0] 99 | assert (key.expiration - key.created_at).total_seconds() == 123 100 | 101 | await kv.set('test_key2', 'test_value', expires=timedelta(seconds=42)) 102 | 103 | keys = await kv.keys(like='test_key2') 104 | assert len(keys) == 1 105 | key = keys[0] 106 | assert (key.expiration - key.created_at).total_seconds() == 60 107 | -------------------------------------------------------------------------------- /cloudkv/sync_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import typing as _typing 4 | from datetime import timedelta 5 | 6 | import httpx as _httpx 7 | 8 | from . import _utils, shared as _shared 9 | 10 | __all__ = ('SyncCloudKV',) 11 | T = _typing.TypeVar('T') 12 | D = _typing.TypeVar('D') 13 | 14 | 15 | class SyncCloudKV: 16 | """Sync client for cloudkv. 17 | 18 | This client can be used either directly after initialization or as a context manager. 19 | """ 20 | 21 | namespace_read_token: str 22 | """Key used to get values and list keys.""" 23 | namespace_write_token: str | None 24 | """Key required to set and delete keys.""" 25 | base_url: str 26 | """Base URL to connect to.""" 27 | _client: _httpx.Client | None = None 28 | 29 | def __init__(self, read_token: str, write_token: str | None, *, base_url: str = _shared.DEFAULT_BASE_URL): 30 | """Initialize a new sync client. 31 | 32 | Args: 33 | read_token: Read API key for the namespace. 34 | write_token: Write API key for the namespace, maybe unset if you only have permission to read values 35 | and list keys. 36 | base_url: Base URL to connect to. 37 | """ 38 | self.namespace_read_token = read_token 39 | self.namespace_write_token = write_token 40 | while base_url.endswith('/'): 41 | base_url = base_url[:-1] 42 | self.base_url = base_url 43 | 44 | @classmethod 45 | def create_namespace(cls, *, base_url: str = _shared.DEFAULT_BASE_URL) -> _shared.CreateNamespaceDetails: 46 | """Create a new namespace, and return details of it. 47 | 48 | Args: 49 | base_url: Base URL to connect to. 50 | 51 | Returns: 52 | `CreateNamespaceDetails` instance with details of the namespace. 53 | """ 54 | response = _httpx.post(f'{base_url}/create') 55 | _shared.ResponseError.check(response) 56 | return _shared.CreateNamespaceDetails.model_validate_json(response.content) 57 | 58 | def __enter__(self): 59 | self._client = _httpx.Client() 60 | self._client.__enter__() 61 | return self 62 | 63 | def __exit__(self, *args: _typing.Any): 64 | assert self._client is not None 65 | self._client.__exit__(*args) 66 | 67 | def get(self, key: str) -> bytes | None: 68 | """Get a value from its key. 69 | 70 | Args: 71 | key: key to lookup 72 | 73 | Returns: 74 | Value as bytes, or `None` if the key does not exist. 75 | """ 76 | return self.get_content_type(key)[0] 77 | 78 | def get_content_type(self, key: str) -> tuple[bytes | None, str | None]: 79 | """Get a value and content-type from a key. 80 | 81 | Args: 82 | key: key to lookup 83 | 84 | Returns: 85 | Value as tuple of `(value, content_type)`, value will be `None` if the key does not exist, 86 | `content_type` will be `None` if the key doesn't exist, or no content-type is set on the key. 87 | """ 88 | assert key, 'Key cannot be empty' 89 | response = self.client.get(f'{self.base_url}/{self.namespace_read_token}/{key}') 90 | _shared.ResponseError.check(response) 91 | if response.status_code == 244: 92 | return None, None 93 | else: 94 | return response.content, response.headers.get('Content-Type') 95 | 96 | def get_as(self, key: str, return_type: type[T], *, default: D = None, force_validate: bool = False) -> T | D: 97 | """Get a value as the given type, or fallback to the `default` value if the value does not exist. 98 | 99 | Internally this method uses pydantic to parse the value as JSON if it has the correct content-type, 100 | "application/json; pydantic". 101 | 102 | Args: 103 | key: key to lookup 104 | return_type: type to of data to return, this type is used to perform validation in the raw value. 105 | default: default value to return if the key does not exist, defaults to None 106 | force_validate: whether to force validation of the value even if the content-type of the value is not 107 | "application/json; pydantic". 108 | 109 | Returns: 110 | The value as the given type, or the default value if the key does not exist. 111 | """ 112 | data, content_type = self.get_content_type(key) 113 | return _utils.decode_value(data, content_type, return_type, default, force_validate) 114 | 115 | def set( 116 | self, 117 | key: str, 118 | value: _typing.Any, 119 | *, 120 | content_type: str | None = None, 121 | expires: int | timedelta | None = None, 122 | ) -> str: 123 | """Set a value in the namespace. 124 | 125 | Args: 126 | key: key to set 127 | value: value to set 128 | content_type: content type of the value, defaults depends on the value type 129 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 130 | key will expire after 10 seconds. 131 | 132 | Returns: 133 | URL of the set operation. 134 | """ 135 | return self.set_details(key, value, content_type=content_type, expires=expires).url 136 | 137 | def set_details( 138 | self, 139 | key: str, 140 | value: _typing.Any, 141 | *, 142 | content_type: str | None = None, 143 | expires: int | timedelta | None = None, 144 | ) -> _shared.KeyInfo: 145 | """Set a value in the namespace and return details. 146 | 147 | Args: 148 | key: key to set 149 | value: value to set 150 | content_type: content type of the value, defaults depends on the value type 151 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 152 | key will expire after 10 seconds. 153 | 154 | Returns: 155 | Details of the key value pair as `KeyInfo`. 156 | """ 157 | if not self.namespace_write_token: 158 | raise RuntimeError("Namespace write key not provided, can't set") 159 | 160 | binary_value, inferred_content_type = _utils.encode_value(value) 161 | content_type = content_type or inferred_content_type 162 | 163 | headers: dict[str, str] = {'authorization': self.namespace_write_token} 164 | if content_type is not None: 165 | headers['Content-Type'] = content_type 166 | 167 | if expires is not None: 168 | headers['Expires'] = str(expires if isinstance(expires, int) else int(expires.total_seconds())) 169 | 170 | response = self.client.post( 171 | f'{self.base_url}/{self.namespace_read_token}/{key}', content=binary_value, headers=headers 172 | ) 173 | _shared.ResponseError.check(response) 174 | return _shared.KeyInfo.model_validate_json(response.content) 175 | 176 | def delete(self, key: str) -> bool: 177 | """Delete a key. 178 | 179 | Args: 180 | key: The key to delete. 181 | 182 | Returns: 183 | True if the key was deleted, False otherwise. 184 | """ 185 | if not self.namespace_write_token: 186 | raise RuntimeError("Namespace write key not provided, can't delete") 187 | headers: dict[str, str] = {'authorization': self.namespace_write_token} 188 | response = self.client.delete(f'{self.base_url}/{self.namespace_read_token}/{key}', headers=headers) 189 | _shared.ResponseError.check(response) 190 | return response.status_code == 200 191 | 192 | @_typing.overload 193 | def keys(self, *, offset: int | None = None) -> list[_shared.KeyInfo]: ... 194 | @_typing.overload 195 | def keys(self, *, starts_with: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 196 | @_typing.overload 197 | def keys(self, *, ends_with: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 198 | @_typing.overload 199 | def keys(self, *, contains: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 200 | @_typing.overload 201 | def keys(self, *, like: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 202 | 203 | def keys( 204 | self, 205 | *, 206 | starts_with: str | None = None, 207 | ends_with: str | None = None, 208 | contains: str | None = None, 209 | like: str | None = None, 210 | offset: int | None = None, 211 | ) -> list[_shared.KeyInfo]: 212 | """List keys in the namespace. 213 | 214 | Parameters `starts_with`, `ends_with`, `contains` and `like` are mutually exclusive - you can only used one 215 | them at a tie. 216 | 217 | Args: 218 | starts_with: Filter to keys that start with this string. 219 | ends_with: Filter to keys that end with this string. 220 | contains: Filter to keys that contain this string. 221 | like: Filter to keys that match this SQL-like pattern. 222 | offset: Offset the results by this number of keys. 223 | 224 | Returns: 225 | A list of keys. 226 | """ 227 | params = _utils.keys_query_params(starts_with, ends_with, contains, like, offset) 228 | 229 | response = self.client.get(f'{self.base_url}/{self.namespace_read_token}', params=params) 230 | _shared.ResponseError.check(response) 231 | return _shared.KeysResponse.model_validate_json(response.content).keys 232 | 233 | @property 234 | def client(self) -> _httpx.Client: 235 | if self._client: 236 | return self._client 237 | else: 238 | # this is a typing lie, but one that works niceli 239 | return _httpx # pyright: ignore[reportReturnType] 240 | -------------------------------------------------------------------------------- /cloudkv/async_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import typing as _typing 4 | from datetime import timedelta 5 | 6 | import httpx as _httpx 7 | 8 | from . import _utils, shared as _shared 9 | 10 | __all__ = ('AsyncCloudKV',) 11 | T = _typing.TypeVar('T') 12 | D = _typing.TypeVar('D') 13 | 14 | 15 | class AsyncCloudKV: 16 | """Async client for cloudkv. 17 | 18 | This client must be used as an async context manager to create a database connection. 19 | """ 20 | 21 | namespace_read_token: str 22 | """Key used to get values and list keys.""" 23 | namespace_write_token: str | None 24 | """Key required to set and delete keys.""" 25 | base_url: str 26 | """Base URL to connect to.""" 27 | _client: _httpx.AsyncClient | None = None 28 | 29 | def __init__(self, read_token: str, write_token: str | None, *, base_url: str = _shared.DEFAULT_BASE_URL): 30 | """Initialize a new async client. 31 | 32 | Args: 33 | read_token: Read API key for the namespace. 34 | write_token: Write API key for the namespace, maybe unset if you only have permission to read values 35 | and list keys. 36 | base_url: Base URL to connect to. 37 | """ 38 | self.namespace_read_token = read_token 39 | self.namespace_write_token = write_token 40 | while base_url.endswith('/'): 41 | base_url = base_url[:-1] 42 | self.base_url = base_url 43 | 44 | @classmethod 45 | async def create_namespace(cls, *, base_url: str = _shared.DEFAULT_BASE_URL) -> _shared.CreateNamespaceDetails: 46 | """Create a new namespace, and return details of it. 47 | 48 | Args: 49 | base_url: Base URL to connect to. 50 | 51 | Returns: 52 | `CreateNamespaceDetails` instance with details of the namespace. 53 | """ 54 | async with _httpx.AsyncClient() as client: 55 | response = await client.post(f'{base_url}/create') 56 | _shared.ResponseError.check(response) 57 | return _shared.CreateNamespaceDetails.model_validate_json(response.content) 58 | 59 | async def __aenter__(self): 60 | self._client = _httpx.AsyncClient() 61 | await self._client.__aenter__() 62 | return self 63 | 64 | async def __aexit__(self, *args: _typing.Any): 65 | assert self._client is not None 66 | await self._client.__aexit__(*args) 67 | 68 | async def get(self, key: str) -> bytes | None: 69 | """Get a value from its key. 70 | 71 | Args: 72 | key: key to lookup 73 | 74 | Returns: 75 | Value as bytes, or `None` if the key does not exist. 76 | """ 77 | value, _ = await self.get_content_type(key) 78 | return value 79 | 80 | async def get_content_type(self, key: str) -> tuple[bytes | None, str | None]: 81 | """Get a value and content-type from a key. 82 | 83 | Args: 84 | key: key to lookup 85 | 86 | Returns: 87 | Value as tuple of `(value, content_type)`, value will be `None` if the key does not exist, 88 | `content_type` will be `None` if the key doesn't exist, or no content-type is set on the key. 89 | """ 90 | assert key, 'Key cannot be empty' 91 | response = await self.client.get(f'{self.base_url}/{self.namespace_read_token}/{key}') 92 | _shared.ResponseError.check(response) 93 | if response.status_code == 244: 94 | return None, None 95 | else: 96 | return response.content, response.headers.get('Content-Type') 97 | 98 | async def get_as(self, key: str, return_type: type[T], *, default: D = None, force_validate: bool = False) -> T | D: 99 | """Get a value as the given type, or fallback to the `default` value if the value does not exist. 100 | 101 | Internally this method uses pydantic to parse the value as JSON if it has the correct content-type, 102 | "application/json; pydantic". 103 | 104 | Args: 105 | key: key to lookup 106 | return_type: type to of data to return, this type is used to perform validation in the raw value. 107 | default: default value to return if the key does not exist, defaults to None 108 | force_validate: whether to force validation of the value even if the content-type of the value is not 109 | "application/json; pydantic". 110 | 111 | Returns: 112 | The value as the given type, or the default value if the key does not exist. 113 | """ 114 | data, content_type = await self.get_content_type(key) 115 | return _utils.decode_value(data, content_type, return_type, default, force_validate) 116 | 117 | async def set( 118 | self, 119 | key: str, 120 | value: _typing.Any, 121 | *, 122 | content_type: str | None = None, 123 | expires: int | timedelta | None = None, 124 | ) -> str: 125 | """Set a value in the namespace. 126 | 127 | Args: 128 | key: key to set 129 | value: value to set 130 | content_type: content type of the value, defaults depends on the value type 131 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 132 | key will expire after 10 seconds. 133 | 134 | Returns: 135 | URL of the set operation. 136 | """ 137 | set_response = await self.set_details(key, value, content_type=content_type, expires=expires) 138 | return set_response.url 139 | 140 | async def set_details( 141 | self, 142 | key: str, 143 | value: _typing.Any, 144 | *, 145 | content_type: str | None = None, 146 | expires: int | timedelta | None = None, 147 | ) -> _shared.KeyInfo: 148 | """Set a value in the namespace and return details. 149 | 150 | Args: 151 | key: key to set 152 | value: value to set 153 | content_type: content type of the value, defaults depends on the value type 154 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 155 | key will expire after 10 seconds. 156 | 157 | Returns: 158 | Details of the key value pair as `KeyInfo`. 159 | """ 160 | if not self.namespace_write_token: 161 | raise RuntimeError("Namespace write key not provided, can't set") 162 | 163 | binary_value, inferred_content_type = _utils.encode_value(value) 164 | content_type = content_type or inferred_content_type 165 | 166 | headers: dict[str, str] = {'authorization': self.namespace_write_token} 167 | if content_type is not None: 168 | headers['Content-Type'] = content_type 169 | if expires is not None: 170 | headers['Expires'] = str(expires if isinstance(expires, int) else int(expires.total_seconds())) 171 | 172 | response = await self.client.post( 173 | f'{self.base_url}/{self.namespace_read_token}/{key}', content=binary_value, headers=headers 174 | ) 175 | _shared.ResponseError.check(response) 176 | return _shared.KeyInfo.model_validate_json(response.content) 177 | 178 | async def delete(self, key: str) -> bool: 179 | """Delete a key. 180 | 181 | Args: 182 | key: The key to delete. 183 | 184 | Returns: 185 | True if the key was deleted, False otherwise. 186 | """ 187 | if not self.namespace_write_token: 188 | raise RuntimeError("Namespace write key not provided, can't delete") 189 | headers: dict[str, str] = {'authorization': self.namespace_write_token} 190 | response = await self.client.delete(f'{self.base_url}/{self.namespace_read_token}/{key}', headers=headers) 191 | _shared.ResponseError.check(response) 192 | return response.status_code == 200 193 | 194 | @_typing.overload 195 | async def keys(self, *, offset: int | None = None) -> list[_shared.KeyInfo]: ... 196 | @_typing.overload 197 | async def keys(self, *, starts_with: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 198 | @_typing.overload 199 | async def keys(self, *, ends_with: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 200 | @_typing.overload 201 | async def keys(self, *, contains: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 202 | @_typing.overload 203 | async def keys(self, *, like: str, offset: int | None = None) -> list[_shared.KeyInfo]: ... 204 | 205 | async def keys( 206 | self, 207 | *, 208 | starts_with: str | None = None, 209 | ends_with: str | None = None, 210 | contains: str | None = None, 211 | like: str | None = None, 212 | offset: int | None = None, 213 | ) -> list[_shared.KeyInfo]: 214 | """List keys in the namespace. 215 | 216 | Parameters `starts_with`, `ends_with`, `contains` and `like` are mutually exclusive - you can only used one 217 | them at a tie. 218 | 219 | Args: 220 | starts_with: Filter to keys that start with this string. 221 | ends_with: Filter to keys that end with this string. 222 | contains: Filter to keys that contain this string. 223 | like: Filter to keys that match this SQL-like pattern. 224 | offset: Offset the results by this number of keys. 225 | 226 | Returns: 227 | A list of keys. 228 | """ 229 | params = _utils.keys_query_params(starts_with, ends_with, contains, like, offset) 230 | 231 | response = await self.client.get(f'{self.base_url}/{self.namespace_read_token}', params=params) 232 | _shared.ResponseError.check(response) 233 | return _shared.KeysResponse.model_validate_json(response.content).keys 234 | 235 | @property 236 | def client(self) -> _httpx.AsyncClient: 237 | if self._client: 238 | return self._client 239 | else: 240 | raise RuntimeError('HTTP client not initialized - AsyncCloudKV must be used as an async context manager') 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudkv 2 | 3 | [![CI](https://github.com/samuelcolvin/cloudkv/actions/workflows/ci.yml/badge.svg)](https://github.com/samuelcolvin/cloudkv/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) 4 | [![pypi](https://img.shields.io/pypi/v/cloudkv.svg)](https://pypi.python.org/pypi/cloudkv) 5 | [![versions](https://img.shields.io/pypi/pyversions/cloudkv.svg)](https://github.com/samuelcolvin/cloudkv) 6 | [![license](https://img.shields.io/github/license/samuelcolvin/cloudkv.svg)](https://github.com/samuelcolvin/cloudkv/blob/main/LICENSE) 7 | 8 | key/value store based on Cloudflare workers, with a Python client. 9 | 10 | By default the `cloudkv` Python package connects to [cloudkv.samuelcolvin.workers.dev](https://cloudkv.samuelcolvin.workers.dev) but you can deploy an instance to your own cloudflare worker if you prefer. Code for the server is in [./cf-worker](https://github.com/samuelcolvin/cloudkv/tree/main/cf-worker). 11 | 12 | Some reasons you might use cloudkv: 13 | * Zero DB setup or account required, just create a namespace with the CLI and get started 14 | * Sync and async clients with almost identical APIs 15 | * Completely open source, deploy your own cloudflare worker if you like or used the hosted one 16 | * Pydantic integration to retrieve values as virtually and Python type 17 | * View any value via it's URL 18 | 19 | ## Installation 20 | 21 | ```bash 22 | uv add cloudkv 23 | ``` 24 | 25 | (or `pip install cloudkv` if you're old school) 26 | 27 | ## Usage 28 | 29 | cloudkv stores key-value pairs in a Cloudflare worker using KV storage and D1. 30 | 31 | To create a namespace, run 32 | 33 | ```bash 34 | uvx cloudkv 35 | ``` 36 | 37 | Which should create a namespace and print the keys to use: 38 | 39 | ``` 40 | creating namespace... 41 | Namespace created successfully. 42 | 43 | cloudkv_read_token = '***' 44 | cloudkv_write_token = '******' 45 | ``` 46 | 47 | _(You can also create a namespace programmatically, see `create_namespace` below)_ 48 | 49 | ### Sync API 50 | 51 | With a namespace created, you can connect thus: 52 | 53 | ```py 54 | from cloudkv import SyncCloudKV 55 | 56 | cloudkv_read_token = '***' 57 | cloudkv_write_token = '******' 58 | kv = SyncCloudKV(cloudkv_read_token, cloudkv_write_token) 59 | url = kv.set('foo', 'bar') 60 | print(url) 61 | #> https://cloudkv.samuelcolvin.workers.dev/***/foo 62 | print(kv.get('foo')) 63 | #> b'bar' 64 | print(kv.get_as('foo', str)) 65 | #> 'bar' 66 | ``` 67 | 68 | Storing structured and retrieving data: 69 | 70 | ```py 71 | from dataclasses import dataclass 72 | from cloudkv import SyncCloudKV 73 | 74 | cloudkv_read_token = '***' 75 | cloudkv_write_token = '******' 76 | 77 | @dataclass 78 | class Foo: 79 | bar: float 80 | spam: list[dict[str, tuple[int, bytes]]] 81 | 82 | kv = SyncCloudKV(cloudkv_read_token, cloudkv_write_token) 83 | foo = Foo(1.23, [{'spam': (1, b'eggs')}]) 84 | url = kv.set('foo', foo) 85 | print(url) 86 | #> https://cloudkv.samuelcolvin.workers.dev/***/foo 87 | print(kv.get('foo')) 88 | #> b'{"bar":1.23,"spam":[{"spam":[1,"eggs"]}]}' 89 | print(kv.get_as('foo', Foo)) 90 | #> Foo(bar=1.23, spam=[{'spam': (1, b'eggs')}]) 91 | ``` 92 | 93 | ### Async API 94 | 95 | You can also connect with the async client. 96 | 97 | The sync and async client's have an identical API except `AsyncCloudKV` must be used as an async context manager, 98 | while `SyncCloudKV` can optionally be used as a context manager or directly after being initialised. 99 | 100 | ```py 101 | import asyncio 102 | from cloudkv import AsyncCloudKV 103 | 104 | cloudkv_read_token = '***' 105 | cloudkv_write_token = '******' 106 | 107 | async def main(): 108 | async with AsyncCloudKV.create(cloudkv_read_token, cloudkv_write_token) as kv: 109 | await kv.set('foo', 'bar') 110 | print(await kv.get('foo')) 111 | #> bar 112 | 113 | asyncio.run(main()) 114 | ``` 115 | 116 | ### API 117 | 118 | `SyncCloudKV` has the follow methods. 119 | 120 | _(`AsyncCloudKV` has identical methods except they're async and it must be used as an async context manager)_ 121 | 122 | ```py 123 | class SyncCloudKV: 124 | """Sync client for cloudkv. 125 | 126 | This client can be used either directly after initialization or as a context manager. 127 | """ 128 | namespace_read_token: str 129 | """Key used to get values and list keys.""" 130 | namespace_write_token: str | None 131 | """Key required to set and delete keys.""" 132 | base_url: str 133 | """Base URL to connect to.""" 134 | 135 | def __init__(self, read_token: str, write_token: str | None, *, base_url: str = ...): 136 | """Initialize a new sync client. 137 | 138 | Args: 139 | read_token: Read API key for the namespace. 140 | write_token: Write API key for the namespace, maybe unset if you only have permission to read values 141 | and list keys. 142 | base_url: Base URL to connect to. 143 | """ 144 | 145 | @classmethod 146 | def create_namespace(cls, *, base_url: str = ...) -> CreateNamespaceDetails: 147 | """Create a new namespace, and return details of it. 148 | 149 | Args: 150 | base_url: Base URL to connect to. 151 | 152 | Returns: 153 | `CreateNamespaceDetails` instance with details of the namespace. 154 | """ 155 | 156 | def __enter__(self): ... 157 | 158 | def __exit__(self, *args): ... 159 | 160 | def get(self, key: str) -> bytes | None: 161 | """Get a value from its key. 162 | 163 | Args: 164 | key: key to lookup 165 | 166 | Returns: 167 | Value as bytes, or `None` if the key does not exist. 168 | """ 169 | 170 | def get_content_type(self, key: str) -> tuple[bytes | None, str | None]: 171 | """Get a value and content-type from a key. 172 | 173 | Args: 174 | key: key to lookup 175 | 176 | Returns: 177 | Value as tuple of `(value, content_type)`, value will be `None` if the key does not exist, 178 | `content_type` will be `None` if the key doesn't exist, or no content-type is set on the key. 179 | """ 180 | 181 | def get_as(self, key: str, return_type: type[T], *, default: D = None, force_validate: bool = False) -> T | D: 182 | '''Get a value as the given type, or fallback to the `default` value if the value does not exist. 183 | 184 | Internally this method uses pydantic to parse the value as JSON if it has the correct content-type, 185 | "application/json; pydantic". 186 | 187 | Args: 188 | key: key to lookup 189 | return_type: type to of data to return, this type is used to perform validation in the raw value. 190 | default: default value to return if the key does not exist, defaults to None 191 | force_validate: whether to force validation of the value even if the content-type of the value is not 192 | "application/json; pydantic". 193 | 194 | Returns: 195 | The value as the given type, or the default value if the key does not exist. 196 | ''' 197 | 198 | def set( 199 | self, 200 | key: str, 201 | value: _typing.Any, 202 | *, 203 | content_type: str | None = None, 204 | expires: int | timedelta | None = None, 205 | ) -> str: 206 | """Set a value in the namespace. 207 | 208 | Args: 209 | key: key to set 210 | value: value to set 211 | content_type: content type of the value, defaults depends on the value type 212 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 213 | key will expire after 10 seconds. 214 | 215 | Returns: 216 | URL of the set operation. 217 | """ 218 | return self.set_details(key, value, content_type=content_type, expires=expires).url 219 | 220 | def set_details( 221 | self, 222 | key: str, 223 | value: _typing.Any, 224 | *, 225 | content_type: str | None = None, 226 | expires: int | timedelta | None = None, 227 | ) -> KeyInfo: 228 | """Set a value in the namespace and return details. 229 | 230 | Args: 231 | key: key to set 232 | value: value to set 233 | content_type: content type of the value, defaults depends on the value type 234 | expires: Time in seconds before the value expires, must be >60 seconds, defaults to `None` meaning the 235 | key will expire after 10 seconds. 236 | 237 | Returns: 238 | Details of the key value pair as `KeyInfo`. 239 | """ 240 | 241 | def delete(self, key: str) -> bool: 242 | """Delete a key. 243 | 244 | Args: 245 | key: The key to delete. 246 | 247 | Returns: 248 | True if the key was deleted, False otherwise. 249 | """ 250 | 251 | def keys( 252 | self, 253 | *, 254 | starts_with: str | None = None, 255 | ends_with: str | None = None, 256 | contains: str | None = None, 257 | like: str | None = None, 258 | offset: int | None = None, 259 | ) -> list[KeyInfo]: 260 | """List keys in the namespace. 261 | 262 | Parameters `starts_with`, `ends_with`, `contains` and `like` are mutually exclusive - you can only used one 263 | them at a tie. 264 | 265 | Args: 266 | starts_with: Filter to keys that start with this string. 267 | ends_with: Filter to keys that end with this string. 268 | contains: Filter to keys that contain this string. 269 | like: Filter to keys that match this SQL-like pattern. 270 | offset: Offset the results by this number of keys. 271 | 272 | Returns: 273 | A list of keys. 274 | """ 275 | ``` 276 | 277 | Types shown above have the following structure: 278 | 279 | ```py 280 | class CreateNamespaceDetails(pydantic.BaseModel): 281 | base_url: str 282 | """Base URL of the namespace""" 283 | read_token: str 284 | """Read API key for the namespace""" 285 | write_token: str 286 | """Write API key for the namespace""" 287 | created_at: datetime 288 | """Creation timestamp of the namespace""" 289 | 290 | 291 | class KeyInfo(pydantic.BaseModel): 292 | url: str 293 | """URL of the key/value""" 294 | key: str 295 | """The key""" 296 | content_type: str | None 297 | """Content type set in the datastore""" 298 | size: int 299 | """Size of the value in bytes""" 300 | created_at: datetime 301 | """Creation timestamp of the key/value""" 302 | expiration: datetime 303 | """Expiration timestamp of the key/value""" 304 | ``` 305 | -------------------------------------------------------------------------------- /cf-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as logfire from '@pydantic/logfire-api' 2 | import { instrument } from '@pydantic/logfire-cf-workers' 3 | 4 | const MB = 1024 * 1024 5 | // maximum number of namespaces that can be created in 24 hours, across all IPs 6 | const MAX_GLOBAL_24 = 1000 7 | // maximum number of namespaces that can be created in 24 hours, per IP 8 | const MAX_IP_24 = 20 9 | // maximum size of a value in bytes, this is a limitation of cloudflare KV 10 | const MAX_VALUE_SIZE_MB = 25 11 | const MAX_VALUE_SIZE = MAX_VALUE_SIZE_MB * MB 12 | // minimum TTL for key is 1 minute, cloudflare limitation 13 | const MIN_EXPIRES = 60 14 | // max TTL for key is 10 years 15 | const MAX_EXPIRES = 60 * 60 * 24 * 365 * 10 16 | const MAX_KEY_SIZE = 2048 17 | 18 | const handler = { 19 | async fetch(request, env, ctx): Promise { 20 | try { 21 | let path = new URL(request.url).pathname 22 | if (path.endsWith('/')) { 23 | path = path.slice(0, -1) 24 | } 25 | // 24 length matches the string resulting from random(18) 26 | const nsMatch = path.match(/^\/([a-zA-Z0-9]{24})\/?(.*)$/) 27 | if (nsMatch) { 28 | const [_, readToken, key] = nsMatch 29 | if (key === '') { 30 | return await list(readToken, request, env) 31 | } else if (request.method === 'GET' || request.method === 'HEAD') { 32 | return await get(readToken, key, request, env) 33 | } else if (request.method === 'POST') { 34 | return await set(readToken, key, request, env, ctx) 35 | } else if (request.method === 'DELETE') { 36 | return await del(readToken, key, request, env, ctx) 37 | } else { 38 | return response405('GET', 'HEAD', 'POST', 'DELETE') 39 | } 40 | } else if (path === '/create') { 41 | return await create(request, env) 42 | } else if (path === '') { 43 | return index(request, env.GITHUB_SHA) 44 | } else { 45 | return textResponse('Path not found', 404) 46 | } 47 | } catch (error) { 48 | logfire.reportError('Internal Server Error', error as Error) 49 | return textResponse('Internal Server Error', 500) 50 | } 51 | }, 52 | } satisfies ExportedHandler 53 | 54 | export default instrument(handler, { 55 | service: { 56 | name: 'cf-worker', 57 | }, 58 | }) 59 | 60 | async function get(readToken: string, key: string, request: Request, env: Env): Promise { 61 | const { value, metadata } = await env.cloudkvData.getWithMetadata(dataKey(readToken, key), 'stream') 62 | if (!value || !metadata) { 63 | // only check the namespace if the key does not exist 64 | const row = await env.DB.prepare('select 1 from namespaces where read_token=?').bind(readToken).first() 65 | if (row) { 66 | return textResponse('Key does not exist', 244) 67 | } else { 68 | return textResponse('Namespace does not exist', 404) 69 | } 70 | } 71 | return new Response(request.method === 'HEAD' ? '' : value, { 72 | headers: metadata.content_type ? { 'Content-Type': metadata.content_type } : {}, 73 | }) 74 | } 75 | 76 | async function set( 77 | readToken: string, 78 | key: string, 79 | request: Request, 80 | env: Env, 81 | ctx: ExecutionContext, 82 | ): Promise { 83 | const auth = getAuth(request) 84 | if (!auth) { 85 | return textResponse('Authorization header not provided', 401) 86 | } 87 | 88 | let content_type = request.headers.get('Content-Type') 89 | if (key.length > MAX_KEY_SIZE) { 90 | return textResponse(`Key length must not exceed ${MAX_KEY_SIZE}`, 414) 91 | } 92 | 93 | let expires: number = MAX_EXPIRES 94 | const expiresHeader = request.headers.get('expires') 95 | if (expiresHeader) { 96 | try { 97 | expires = parseInt(expiresHeader) 98 | } catch (error) { 99 | return textResponse(`Invalid "Expires" header "${expiresHeader}": not a valid number`, 400) 100 | } 101 | // clamp expires to valid range 102 | expires = Math.max(MIN_EXPIRES, Math.min(expires, MAX_EXPIRES)) 103 | } 104 | 105 | const body = await request.arrayBuffer() 106 | const size = body.byteLength 107 | if (!size) { 108 | return textResponse('To set a key, the request body must not be empty', 400) 109 | } 110 | if (size > MAX_VALUE_SIZE) { 111 | return textResponse(`Value size must not exceed ${MAX_VALUE_SIZE_MB}MB`, 413) 112 | } 113 | 114 | const row = await env.DB.prepare( 115 | ` 116 | insert into kv 117 | (namespace, key, content_type, size, expiration) 118 | select ?, ?, ?, ?, datetime('now', ?) 119 | where 120 | (select write_token from namespaces where read_token = ?) = ? 121 | and ( 122 | select coalesce(sum(size), 0) 123 | from kv 124 | where namespace = ? and key != ? and expiration > datetime('now') 125 | ) <= ? 126 | on conflict do update set 127 | content_type = excluded.content_type, 128 | size = excluded.size, 129 | created_at = datetime('now'), 130 | expiration = excluded.expiration 131 | returning 132 | ${sqlIsoDate('created_at')} as created_at, 133 | ${sqlIsoDate('expiration')} as expiration`, 134 | ) 135 | .bind( 136 | readToken, 137 | key, 138 | content_type, 139 | size, 140 | `+${expires} seconds`, 141 | readToken, 142 | auth, 143 | readToken, 144 | key, 145 | env.NAMESPACE_SIZE_LIMIT - size, 146 | ) 147 | .first<{ created_at: string; expiration: string }>() 148 | 149 | if (!row) { 150 | const writeTokenRow = await env.DB.prepare('select write_token from namespaces where read_token = ?') 151 | .bind(readToken) 152 | .first<{ write_token: string }>() 153 | 154 | if (!writeTokenRow) { 155 | return textResponse('Namespace does not exist', 404) 156 | } else if (auth != writeTokenRow.write_token) { 157 | return textResponse('Authorization header does not match write key', 403) 158 | } else { 159 | return textResponse( 160 | `Namespace size limit of ${Math.round(env.NAMESPACE_SIZE_LIMIT / MB)}MB would be exceeded`, 161 | 413, 162 | ) 163 | } 164 | } 165 | 166 | const { created_at, expiration } = row 167 | const expirationDate = new Date(expiration) 168 | await env.cloudkvData.put(dataKey(readToken, key), body, { 169 | expirationTtl: expirationDate.getTime() / 1000, 170 | metadata: { content_type } satisfies KVMetadata, 171 | }) 172 | ctx.waitUntil( 173 | env.DB.prepare("delete from kv where namespace = ? and expiration < datetime('now')").bind(readToken).run(), 174 | ) 175 | 176 | const url = new URL(request.url) 177 | url.search = '' 178 | url.hash = '' 179 | return jsonResponse({ 180 | url, 181 | key, 182 | content_type, 183 | size, 184 | created_at, 185 | expiration, 186 | }) 187 | } 188 | 189 | async function del( 190 | readToken: string, 191 | key: string, 192 | request: Request, 193 | env: Env, 194 | ctx: ExecutionContext, 195 | ): Promise { 196 | const auth = getAuth(request) 197 | if (!auth) { 198 | return textResponse('Authorization header not provided', 401) 199 | } 200 | 201 | let row = await env.DB.prepare(`select write_token as writeKey from namespaces where read_token = ?`) 202 | .bind(readToken) 203 | .first<{ writeKey: string }>() 204 | 205 | if (!row) { 206 | return textResponse('Namespace does not exist', 404) 207 | } 208 | const { writeKey } = row 209 | if (auth != writeKey) { 210 | return textResponse('Authorization header does not match write key', 403) 211 | } 212 | 213 | await env.cloudkvData.delete(dataKey(readToken, key)) 214 | const deleteRow = await env.DB.prepare('delete from kv where namespace=? and key=? returning size') 215 | .bind(readToken, key) 216 | .first() 217 | 218 | ctx.waitUntil( 219 | env.DB.prepare("delete from kv where namespace = ? and expiration < datetime('now')").bind(readToken).run(), 220 | ) 221 | 222 | if (deleteRow) { 223 | return textResponse('Key deleted', 200) 224 | } else { 225 | return textResponse('Key not found', 244) 226 | } 227 | } 228 | 229 | interface KVMetadata { 230 | content_type: string | null 231 | } 232 | 233 | interface DbRow { 234 | key: string 235 | content_type: string 236 | size: number 237 | created_at: string 238 | expiration: string 239 | } 240 | 241 | interface ListKey extends DbRow { 242 | url: string 243 | } 244 | 245 | interface ListResponse { 246 | keys: ListKey[] 247 | } 248 | 249 | async function list(readToken: string, request: Request, env: Env): Promise { 250 | if (request.method !== 'GET') { 251 | return response405('GET') 252 | } 253 | 254 | const nsExists = await env.DB.prepare('select 1 from namespaces where read_token=?').bind(readToken).first() 255 | if (!nsExists) { 256 | return textResponse('Namespace does not exist', 404) 257 | } 258 | 259 | const url = new URL(request.url) 260 | const like = url.searchParams.get('like') 261 | let offset = 0 262 | let offsetParam = url.searchParams.get('offset') 263 | if (offsetParam) { 264 | try { 265 | offset = parseInt(offsetParam) 266 | } catch (error) { 267 | return textResponse('Invalid offset', 400) 268 | } 269 | } 270 | // clean the URL to use when building the key URL 271 | url.pathname = `/${readToken}` 272 | url.search = '' 273 | url.hash = '' 274 | 275 | const params = like ? [readToken, like, offset] : [readToken, offset] 276 | const result = await env.DB.prepare( 277 | ` 278 | select 279 | key, 280 | content_type, 281 | size, 282 | ${sqlIsoDate('created_at')} as created_at, 283 | ${sqlIsoDate('expiration')} as expiration 284 | from kv 285 | where namespace = ? and expiration > datetime('now') ${like ? `and key like ?` : ''} 286 | order by created_at 287 | limit 1000 288 | offset ? 289 | `, 290 | ) 291 | .bind(...params) 292 | .all() 293 | const keys = result.results.map((row) => ({ 294 | url: `${url}/${row.key}`, 295 | ...row, 296 | })) as ListKey[] 297 | 298 | const response: ListResponse = { keys } 299 | return jsonResponse(response) 300 | } 301 | 302 | async function create(request: Request, env: Env): Promise { 303 | if (request.method !== 'POST') { 304 | return response405('POST') 305 | } 306 | const ip = getIP(request) 307 | let { globalCount, ipCount } = (await env.DB.prepare( 308 | ` 309 | select 310 | count(*) as globalCount, 311 | count(case when ip = ? then 1 end) as ipCount 312 | from namespaces 313 | where created_at > datetime('now', '-24 hours') 314 | `, 315 | ) 316 | .bind(ip) 317 | .first<{ globalCount: number; ipCount: number }>())! 318 | 319 | if (globalCount > MAX_GLOBAL_24) { 320 | logfire.warning('Global NS limit exceeded', { globalCount, ipCount }) 321 | return textResponse(`Global limit (${MAX_GLOBAL_24}) on namespace creation per 24 hours exceeded`, 429) 322 | } else if (ipCount > MAX_IP_24) { 323 | logfire.warning('IP NS limit exceeded', { globalCount, ipCount }) 324 | return textResponse(`IP limit (${MAX_IP_24}) on namespace creation per 24 hours exceeded`, 429) 325 | } 326 | 327 | const url = new URL(request.url) 328 | url.pathname = '' 329 | url.search = '' 330 | url.hash = '' 331 | const base_url = url.toString().slice(0, -1) 332 | 333 | while (true) { 334 | // 18 bytes always results in a string of length 24 335 | const read_token = random(18) 336 | // 36 bytes always results in a string of length 48 337 | const write_token = random(36) 338 | const row = await env.DB.prepare( 339 | ` 340 | insert into namespaces (read_token, write_token, ip) values (?, ?, ?) 341 | on conflict do nothing 342 | returning ${sqlIsoDate('created_at')} as created_at 343 | `, 344 | ) 345 | .bind(read_token, write_token, ip) 346 | .first<{ created_at: string }>() 347 | if (row) { 348 | const { created_at } = row 349 | logfire.info('Namespace created', { read_token, created_at, ip }) 350 | return jsonResponse({ base_url, read_token, write_token, created_at }) 351 | } 352 | } 353 | } 354 | 355 | function index(request: Request, githubSha: string): Response { 356 | const releaseNote = githubSha.startsWith('[') 357 | ? githubSha 358 | : `${githubSha.substring(0, 7)}` 359 | if (request.method === 'GET') { 360 | return new Response( 361 | `\ 362 |

cloudkv

363 |

See github.com/samuelcolvin/cloudkv for details.

364 |

release: ${releaseNote}

365 | `, 366 | { 367 | headers: ctHeader('text/html'), 368 | }, 369 | ) 370 | } else if (request.method === 'HEAD') { 371 | return new Response('', { headers: ctHeader('text/html') }) 372 | } else { 373 | return response405('GET', 'HEAD') 374 | } 375 | } 376 | 377 | function getAuth(request: Request): string | null { 378 | let auth = request.headers.get('Authorization') 379 | if (auth && auth.toLowerCase().startsWith('bearer ')) { 380 | auth = auth.slice(7) 381 | } 382 | return auth 383 | } 384 | 385 | const dataKey = (namespace: string, key: string) => `data:${namespace}:${key}` 386 | 387 | const ctHeader = (contentType: string) => ({ 'Content-Type': contentType }) 388 | 389 | const textResponse = (message: string, status: number) => 390 | new Response(message, { status, headers: ctHeader('text/plain') }) 391 | const jsonResponse = (data: any) => 392 | new Response(JSON.stringify(data, null, 2) + '\n', { headers: ctHeader('application/json') }) 393 | 394 | function response405(...allowMethods: string[]): Response { 395 | const allow = allowMethods.join(', ') 396 | return new Response(`405: Method not allowed, Allowed: ${allow}`, { 397 | status: 405, 398 | headers: { allow, ...ctHeader('text/plain') }, 399 | }) 400 | } 401 | 402 | function getIP(request: Request): string { 403 | const ip = request.headers.get('cf-connecting-ip') 404 | if (ip) { 405 | return ip 406 | } else { 407 | throw new Error('IP address not found') 408 | } 409 | } 410 | const sqlIsoDate = (field: 'created_at' | 'expiration') => `strftime('%Y-%m-%dT%H:%M:%SZ', ${field})` 411 | 412 | /// Generate a random string and encode it as URL safe base64 413 | function random(bytes: number): string { 414 | const uint8Array = new Uint8Array(bytes) 415 | crypto.getRandomValues(uint8Array) 416 | // Convert Uint8Array to binary string 417 | const binaryString = String.fromCharCode.apply(null, Array.from(uint8Array)) 418 | // Encode to base64, and replace `/` with 'a' and `+` with 'b' and `=` with 'c' 419 | // (this reduces entropy very slightly but makes the secret alpha numeric and easier to use) 420 | return btoa(binaryString).replaceAll('/', 'a').replaceAll('+', 'b').replaceAll('=', 'c') 421 | } 422 | -------------------------------------------------------------------------------- /cf-worker/test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { env, SELF } from 'cloudflare:test' 2 | import { describe, it, expect, beforeAll } from 'vitest' 3 | // @ts-ignore 4 | import SQL from '../schema.sql?raw' 5 | 6 | interface CreateNamespace { 7 | base_url: string 8 | read_token: string 9 | write_token: string 10 | created_at: string 11 | } 12 | 13 | interface SetKV { 14 | url: string 15 | key: string 16 | content_type: string 17 | size: number 18 | created_at: string 19 | expiration: string 20 | } 21 | 22 | interface ListKey { 23 | url: string 24 | key: string 25 | content_type: string | null 26 | size: number 27 | created_at: string 28 | expiration: string 29 | } 30 | 31 | interface ListResponse { 32 | keys: ListKey[] 33 | } 34 | 35 | const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/ 36 | 37 | describe('cf-worker', () => { 38 | beforeAll(async () => { 39 | await env.DB.prepare( 40 | ` 41 | DROP TABLE IF EXISTS namespaces; 42 | DROP TABLE IF EXISTS kv; 43 | 44 | ${SQL} 45 | `, 46 | ).run() 47 | }) 48 | 49 | it('responds with index html', async () => { 50 | const response = await SELF.fetch('https://example.com') 51 | expect(response.status).toBe(200) 52 | expect(await response.text()).toMatchInlineSnapshot( 53 | ` 54 | "

cloudkv

55 |

See github.com/samuelcolvin/cloudkv for details.

56 |

release: [unknown]

57 | " 58 | `, 59 | ) 60 | }) 61 | 62 | it('response 404', async () => { 63 | const response = await SELF.fetch('https://example.com/404') 64 | expect(response.status).toBe(404) 65 | expect(await response.text()).toMatchInlineSnapshot(`"Path not found"`) 66 | }) 67 | 68 | it('creates a KV namespace', async () => { 69 | const response = await SELF.fetch('https://example.com/create', { 70 | method: 'POST', 71 | headers: { 'cf-connecting-ip': '::1' }, 72 | }) 73 | expect(response.status).toBe(200) 74 | const data = await response.json() 75 | expect(Object.keys(data)).toEqual(['base_url', 'read_token', 'write_token', 'created_at']) 76 | expect(data.base_url).toEqual('https://example.com') 77 | expect(data.read_token.length).toBe(24) 78 | expect(data.write_token.length).toBe(48) 79 | expect(data.created_at).toMatch(iso8601Regex) 80 | 81 | const response2 = await SELF.fetch('https://example.com/create', { 82 | method: 'POST', 83 | headers: { 'cf-connecting-ip': '::1' }, 84 | }) 85 | expect(response2.status).toBe(200) 86 | const data2 = await response2.json() 87 | expect(data2.read_token).not.toBe(data.read_token) 88 | expect(data2.write_token).not.toBe(data.write_token) 89 | }) 90 | 91 | it('set a KV, no content type', async () => { 92 | const createResponse = await SELF.fetch('https://example.com/create/', { 93 | method: 'POST', 94 | headers: { 'cf-connecting-ip': '::1' }, 95 | }) 96 | const { read_token, write_token } = await createResponse.json() 97 | 98 | const setResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 99 | method: 'POST', 100 | body: 'testing', 101 | headers: { 'content-type': '', Authorization: write_token }, 102 | }) 103 | expect(setResponse.status).toBe(200) 104 | const setData = await setResponse.json() 105 | expect(setData.url).toEqual(`https://example.com/${read_token}/foobar.json`) 106 | expect(setData.key).toMatchInlineSnapshot(`"foobar.json"`) 107 | expect(setData.content_type).toBeNull() 108 | expect(setData.size).toMatchInlineSnapshot(`7`) 109 | expect(setData.created_at).toMatch(iso8601Regex) 110 | expect(setData.expiration).toMatch(iso8601Regex) 111 | 112 | const getResponse = await SELF.fetch(setData.url) 113 | expect(getResponse.status).toBe(200) 114 | const text = await getResponse.text() 115 | expect(text).toMatchInlineSnapshot(`"testing"`) 116 | const contentType = getResponse.headers.get('content-type') 117 | expect(contentType).toBeNull() 118 | }) 119 | 120 | it('set a KV, get a value', async () => { 121 | const createResponse = await SELF.fetch('https://example.com/create/', { 122 | method: 'POST', 123 | headers: { 'cf-connecting-ip': '::1' }, 124 | }) 125 | const { read_token, write_token } = await createResponse.json() 126 | 127 | const setResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 128 | method: 'POST', 129 | body: 'testing', 130 | headers: { 'Content-Type': 'text/plain', Authorization: `Bearer ${write_token}` }, 131 | }) 132 | expect(setResponse.status).toBe(200) 133 | const setData = await setResponse.json() 134 | expect(setData.url).toEqual(`https://example.com/${read_token}/foobar.json`) 135 | expect(setData.key).toMatchInlineSnapshot(`"foobar.json"`) 136 | expect(setData.content_type).toMatchInlineSnapshot(`"text/plain"`) 137 | expect(setData.size).toMatchInlineSnapshot(`7`) 138 | expect(setData.created_at).toMatch(iso8601Regex) 139 | expect(setData.expiration).toMatch(iso8601Regex) 140 | 141 | const getResponse = await SELF.fetch(setData.url) 142 | expect(getResponse.status).toBe(200) 143 | const text = await getResponse.text() 144 | expect(text).toMatchInlineSnapshot(`"testing"`) 145 | const contentType = getResponse.headers.get('content-type') 146 | expect(contentType).toMatchInlineSnapshot(`"text/plain"`) 147 | }) 148 | 149 | it('get no namespace', async () => { 150 | const response = await SELF.fetch(`https://example.com/${'1'.repeat(24)}/foobar.json`) 151 | expect(response.status).toBe(404) 152 | expect(await response.text()).toEqual('Namespace does not exist') 153 | }) 154 | 155 | it('get no key', async () => { 156 | const createResponse = await SELF.fetch('https://example.com/create/', { 157 | method: 'POST', 158 | headers: { 'cf-connecting-ip': '::1' }, 159 | }) 160 | const { read_token } = await createResponse.json() 161 | 162 | const response = await SELF.fetch(`https://example.com/${read_token}/foobar.json`) 163 | expect(response.status).toBe(244) 164 | expect(await response.text()).toEqual('Key does not exist') 165 | }) 166 | 167 | it('set a KV, list', async () => { 168 | const createResponse = await SELF.fetch('https://example.com/create', { 169 | method: 'POST', 170 | headers: { 'cf-connecting-ip': '::1' }, 171 | }) 172 | const { read_token, write_token } = await createResponse.json() 173 | 174 | const setResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 175 | method: 'POST', 176 | body: 'testing', 177 | headers: { 'Content-Type': 'text/plain', Authorization: write_token }, 178 | }) 179 | expect(setResponse.status).toBe(200) 180 | const setData = await setResponse.json() 181 | 182 | const listResponse = await SELF.fetch(`https://example.com/${read_token}/`) 183 | expect(listResponse.status).toBe(200) 184 | const listData = await listResponse.json() 185 | expect(listData.keys.length).toBe(1) 186 | expect(listData.keys[0].key).toMatchInlineSnapshot(`"foobar.json"`) 187 | expect(listData.keys[0].content_type).toMatchInlineSnapshot(`"text/plain"`) 188 | expect(listData.keys[0].size).toMatchInlineSnapshot(`7`) 189 | expect(listData.keys[0].created_at).toMatch(iso8601Regex) 190 | expect(listData.keys[0].expiration).toMatch(iso8601Regex) 191 | expect(listData.keys[0].url).toBe(setData.url) 192 | 193 | const listLikeMatchResponse = await SELF.fetch(`https://example.com/${read_token}/?like=%foo%`) 194 | expect(listLikeMatchResponse.status).toBe(200) 195 | const listLikeMatchData = await listLikeMatchResponse.json() 196 | expect(listLikeMatchData.keys.length).toBe(1) 197 | 198 | const listLikeNoMatchResponse = await SELF.fetch(`https://example.com/${read_token}/?like=%xxx%`) 199 | expect(listLikeNoMatchResponse.status).toBe(200) 200 | const listLikeNoMatchData = await listLikeNoMatchResponse.json() 201 | expect(listLikeNoMatchData.keys.length).toBe(0) 202 | }) 203 | 204 | it('returns 404 for invalid read key', async () => { 205 | const response = await SELF.fetch(`https://example.com/${'1'.repeat(20)}/foobar.json`, { 206 | method: 'POST', 207 | body: 'testing', 208 | headers: { 'Content-Type': 'text/plain', authorization: 'xxx' }, 209 | }) 210 | expect(response.status).toBe(404) 211 | expect(await response.text()).toEqual('Path not found') 212 | }) 213 | 214 | it('returns 404 for unknown read key', async () => { 215 | const wrongReadKeyResponse = await SELF.fetch(`https://example.com/${'1'.repeat(24)}/foobar.json`, { 216 | method: 'POST', 217 | body: 'testing', 218 | headers: { 'Content-Type': 'text/plain', authorization: 'xxx' }, 219 | }) 220 | expect(wrongReadKeyResponse.status).toBe(404) 221 | expect(await wrongReadKeyResponse.text()).toEqual('Namespace does not exist') 222 | }) 223 | 224 | it('returns 401 or 403 for bad write key', async () => { 225 | const createResponse = await SELF.fetch('https://example.com/create', { 226 | method: 'POST', 227 | headers: { 'cf-connecting-ip': '::1' }, 228 | }) 229 | const { read_token, write_token } = await createResponse.json() 230 | 231 | const noAuthResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 232 | method: 'POST', 233 | body: 'testing', 234 | headers: { 'Content-Type': 'text/plain' }, 235 | }) 236 | expect(noAuthResponse.status).toBe(401) 237 | expect(await noAuthResponse.text()).toEqual('Authorization header not provided') 238 | 239 | const wrongAuthResponse1 = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 240 | method: 'POST', 241 | body: 'testing', 242 | headers: { 'Content-Type': 'text/plain', authorization: 'xxx' }, 243 | }) 244 | expect(wrongAuthResponse1.status).toBe(403) 245 | expect(await wrongAuthResponse1.text()).toEqual('Authorization header does not match write key') 246 | 247 | // write key length, but wrong case 248 | const wrongAuthResponse2 = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 249 | method: 'POST', 250 | body: 'testing', 251 | headers: { 'Content-Type': 'text/plain', authorization: write_token.toLowerCase() }, 252 | }) 253 | expect(wrongAuthResponse2.status).toBe(403) 254 | expect(await wrongAuthResponse2.text()).toEqual('Authorization header does not match write key') 255 | }) 256 | 257 | it('set, gets, delete, get, delete', async () => { 258 | const createResponse = await SELF.fetch('https://example.com/create/', { 259 | method: 'POST', 260 | headers: { 'cf-connecting-ip': '::1' }, 261 | }) 262 | const { read_token, write_token } = await createResponse.json() 263 | 264 | const setResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 265 | method: 'POST', 266 | body: 'testing', 267 | headers: { Authorization: write_token }, 268 | }) 269 | expect(setResponse.status).toBe(200) 270 | const setData = await setResponse.json() 271 | 272 | const getResponse = await SELF.fetch(setData.url) 273 | expect(getResponse.status).toBe(200) 274 | expect(await getResponse.text()).toEqual('testing') 275 | 276 | const deleteResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 277 | method: 'DELETE', 278 | headers: { Authorization: write_token }, 279 | }) 280 | expect(deleteResponse.status).toBe(200) 281 | expect(await deleteResponse.text()).toEqual('Key deleted') 282 | 283 | const getResponse2 = await SELF.fetch(setData.url) 284 | expect(getResponse2.status).toBe(244) 285 | expect(await getResponse2.text()).toEqual('Key does not exist') 286 | 287 | const deleteResponse2 = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 288 | method: 'DELETE', 289 | headers: { Authorization: write_token }, 290 | }) 291 | expect(deleteResponse2.status).toBe(244) 292 | expect(await deleteResponse2.text()).toEqual('Key not found') 293 | }) 294 | 295 | it('delete no auth', async () => { 296 | const deleteResponse = await SELF.fetch(`https://example.com/${'1'.repeat(24)}/foobar.json`, { 297 | method: 'DELETE', 298 | }) 299 | expect(deleteResponse.status).toBe(401) 300 | expect(await deleteResponse.text()).toEqual('Authorization header not provided') 301 | }) 302 | 303 | it('delete wrong auth', async () => { 304 | const createResponse = await SELF.fetch('https://example.com/create/', { 305 | method: 'POST', 306 | headers: { 'cf-connecting-ip': '::1' }, 307 | }) 308 | const { read_token, write_token } = await createResponse.json() 309 | 310 | const setResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 311 | method: 'POST', 312 | body: 'testing', 313 | headers: { Authorization: write_token }, 314 | }) 315 | expect(setResponse.status).toBe(200) 316 | const setData = await setResponse.json() 317 | 318 | const deleteResponse = await SELF.fetch(`https://example.com/${read_token}/foobar.json`, { 319 | method: 'DELETE', 320 | headers: { Authorization: write_token.toLowerCase() }, 321 | }) 322 | expect(deleteResponse.status).toBe(403) 323 | expect(await deleteResponse.text()).toEqual('Authorization header does not match write key') 324 | 325 | const getResponse = await SELF.fetch(setData.url) 326 | expect(getResponse.status).toBe(200) 327 | expect(await getResponse.text()).toEqual('testing') 328 | }) 329 | 330 | it('delete no namespace', async () => { 331 | const deleteResponse = await SELF.fetch(`https://example.com/${'1'.repeat(24)}/foobar.json`, { 332 | method: 'DELETE', 333 | headers: { Authorization: '1'.repeat(48) }, 334 | }) 335 | expect(deleteResponse.status).toBe(404) 336 | expect(await deleteResponse.text()).toEqual('Namespace does not exist') 337 | }) 338 | 339 | it('set key exceed namespace limit', async () => { 340 | const createResponse = await SELF.fetch('https://example.com/create/', { 341 | method: 'POST', 342 | headers: { 'cf-connecting-ip': '::1' }, 343 | }) 344 | const { read_token, write_token } = await createResponse.json() 345 | 346 | const setResponse1 = await SELF.fetch(`https://example.com/${read_token}/foobar`, { 347 | method: 'POST', 348 | body: '12345', 349 | headers: { Authorization: write_token }, 350 | }) 351 | expect(setResponse1.status).toBe(200) 352 | const setData1 = await setResponse1.json() 353 | 354 | const getResponse = await SELF.fetch(setData1.url) 355 | expect(getResponse.status).toBe(200) 356 | const text = await getResponse.text() 357 | expect(text).toEqual('12345') 358 | 359 | const setResponse2 = await SELF.fetch(`https://example.com/${read_token}/foobar`, { 360 | method: 'POST', 361 | body: '123456', 362 | headers: { Authorization: write_token }, 363 | }) 364 | expect(setResponse2.status).toBe(200) 365 | 366 | const setResponse3 = await SELF.fetch(`https://example.com/${read_token}/bar`, { 367 | method: 'POST', 368 | body: '12345', 369 | headers: { Authorization: write_token }, 370 | }) 371 | expect(setResponse3.status).toBe(413) 372 | expect(await setResponse3.text()).toEqual('Namespace size limit of 0MB would be exceeded') 373 | }) 374 | }) 375 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.9" 4 | resolution-markers = [ 5 | "python_full_version >= '3.10'", 6 | "python_full_version < '3.10'", 7 | ] 8 | 9 | [[package]] 10 | name = "annotated-types" 11 | version = "0.7.0" 12 | source = { registry = "https://pypi.org/simple" } 13 | 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" } 14 | wheels = [ 15 | { 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" }, 16 | ] 17 | 18 | [[package]] 19 | name = "anyio" 20 | version = "4.9.0" 21 | source = { registry = "https://pypi.org/simple" } 22 | dependencies = [ 23 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 24 | { name = "idna" }, 25 | { name = "sniffio" }, 26 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 27 | ] 28 | sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } 29 | wheels = [ 30 | { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, 31 | ] 32 | 33 | [[package]] 34 | name = "asttokens" 35 | version = "2.4.1" 36 | source = { registry = "https://pypi.org/simple" } 37 | dependencies = [ 38 | { name = "six" }, 39 | ] 40 | sdist = { url = "https://files.pythonhosted.org/packages/45/1d/f03bcb60c4a3212e15f99a56085d93093a497718adf828d050b9d675da81/asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0", size = 62284, upload-time = "2023-10-26T10:03:05.06Z" } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764, upload-time = "2023-10-26T10:03:01.789Z" }, 43 | ] 44 | 45 | [[package]] 46 | name = "black" 47 | version = "25.1.0" 48 | source = { registry = "https://pypi.org/simple" } 49 | dependencies = [ 50 | { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 51 | { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 52 | { name = "mypy-extensions" }, 53 | { name = "packaging" }, 54 | { name = "pathspec" }, 55 | { name = "platformdirs" }, 56 | { name = "tomli", marker = "python_full_version < '3.11'" }, 57 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 58 | ] 59 | sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } 60 | wheels = [ 61 | { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, 62 | { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, 63 | { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, 64 | { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, 65 | { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, 66 | { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, 67 | { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, 68 | { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, 69 | { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, 70 | { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, 71 | { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, 72 | { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, 73 | { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, 74 | { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, 75 | { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, 76 | { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, 77 | { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593, upload-time = "2025-01-29T05:37:23.672Z" }, 78 | { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000, upload-time = "2025-01-29T05:37:25.829Z" }, 79 | { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963, upload-time = "2025-01-29T04:18:38.116Z" }, 80 | { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419, upload-time = "2025-01-29T04:18:30.191Z" }, 81 | { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, 82 | ] 83 | 84 | [[package]] 85 | name = "certifi" 86 | version = "2025.4.26" 87 | source = { registry = "https://pypi.org/simple" } 88 | sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } 89 | wheels = [ 90 | { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, 91 | ] 92 | 93 | [[package]] 94 | name = "click" 95 | version = "8.1.8" 96 | source = { registry = "https://pypi.org/simple" } 97 | resolution-markers = [ 98 | "python_full_version < '3.10'", 99 | ] 100 | dependencies = [ 101 | { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, 102 | ] 103 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } 104 | wheels = [ 105 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, 106 | ] 107 | 108 | [[package]] 109 | name = "click" 110 | version = "8.2.1" 111 | source = { registry = "https://pypi.org/simple" } 112 | resolution-markers = [ 113 | "python_full_version >= '3.10'", 114 | ] 115 | dependencies = [ 116 | { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, 117 | ] 118 | sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } 119 | wheels = [ 120 | { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, 121 | ] 122 | 123 | [[package]] 124 | name = "cloudkv" 125 | version = "0.3.0" 126 | source = { editable = "." } 127 | dependencies = [ 128 | { name = "eval-type-backport" }, 129 | { name = "httpx" }, 130 | { name = "pydantic" }, 131 | ] 132 | 133 | [package.dev-dependencies] 134 | dev = [ 135 | { name = "anyio" }, 136 | { name = "coverage", extra = ["toml"] }, 137 | { name = "devtools" }, 138 | { name = "dirty-equals" }, 139 | { name = "inline-snapshot", extra = ["black"] }, 140 | { name = "pyright" }, 141 | { name = "pytest" }, 142 | { name = "pytest-pretty" }, 143 | { name = "ruff" }, 144 | ] 145 | 146 | [package.metadata] 147 | requires-dist = [ 148 | { name = "eval-type-backport", specifier = ">=0.2.2" }, 149 | { name = "httpx", specifier = ">=0.28.1" }, 150 | { name = "pydantic", specifier = ">=2.11.5" }, 151 | ] 152 | 153 | [package.metadata.requires-dev] 154 | dev = [ 155 | { name = "anyio", specifier = ">=4.9.0" }, 156 | { name = "coverage", extras = ["toml"], specifier = ">=7.8.2" }, 157 | { name = "devtools", specifier = ">=0.12.2" }, 158 | { name = "dirty-equals", specifier = ">=0.9.0" }, 159 | { name = "inline-snapshot", extras = ["black"], specifier = ">=0.23.2" }, 160 | { name = "pyright", specifier = ">=1.1.398" }, 161 | { name = "pytest", specifier = ">=8.4.0" }, 162 | { name = "pytest-pretty", specifier = ">=1.3.0" }, 163 | { name = "ruff", specifier = ">=0.11.11" }, 164 | ] 165 | 166 | [[package]] 167 | name = "colorama" 168 | version = "0.4.6" 169 | source = { registry = "https://pypi.org/simple" } 170 | 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" } 171 | wheels = [ 172 | { 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" }, 173 | ] 174 | 175 | [[package]] 176 | name = "coverage" 177 | version = "7.8.2" 178 | source = { registry = "https://pypi.org/simple" } 179 | sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } 180 | wheels = [ 181 | { url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" }, 182 | { url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" }, 183 | { url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" }, 184 | { url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" }, 185 | { url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" }, 186 | { url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" }, 187 | { url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" }, 188 | { url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" }, 189 | { url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" }, 190 | { url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" }, 191 | { url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" }, 192 | { url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" }, 193 | { url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" }, 194 | { url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" }, 195 | { url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" }, 196 | { url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" }, 197 | { url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" }, 198 | { url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" }, 199 | { url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" }, 200 | { url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" }, 201 | { url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" }, 202 | { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, 203 | { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, 204 | { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, 205 | { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, 206 | { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, 207 | { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, 208 | { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, 209 | { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, 210 | { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, 211 | { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, 212 | { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, 213 | { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, 214 | { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, 215 | { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, 216 | { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, 217 | { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, 218 | { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, 219 | { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, 220 | { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, 221 | { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, 222 | { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, 223 | { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, 224 | { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, 225 | { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, 226 | { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, 227 | { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, 228 | { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, 229 | { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, 230 | { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, 231 | { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, 232 | { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, 233 | { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, 234 | { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, 235 | { url = "https://files.pythonhosted.org/packages/71/1e/388267ad9c6aa126438acc1ceafede3bb746afa9872e3ec5f0691b7d5efa/coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a", size = 211566, upload-time = "2025-05-23T11:39:32.333Z" }, 236 | { url = "https://files.pythonhosted.org/packages/8f/a5/acc03e5cf0bba6357f5e7c676343de40fbf431bb1e115fbebf24b2f7f65e/coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d", size = 211996, upload-time = "2025-05-23T11:39:34.512Z" }, 237 | { url = "https://files.pythonhosted.org/packages/5b/a2/0fc0a9f6b7c24fa4f1d7210d782c38cb0d5e692666c36eaeae9a441b6755/coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca", size = 240741, upload-time = "2025-05-23T11:39:36.252Z" }, 238 | { url = "https://files.pythonhosted.org/packages/e6/da/1c6ba2cf259710eed8916d4fd201dccc6be7380ad2b3b9f63ece3285d809/coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d", size = 238672, upload-time = "2025-05-23T11:39:38.03Z" }, 239 | { url = "https://files.pythonhosted.org/packages/ac/51/c8fae0dc3ca421e6e2509503696f910ff333258db672800c3bdef256265a/coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787", size = 239769, upload-time = "2025-05-23T11:39:40.24Z" }, 240 | { url = "https://files.pythonhosted.org/packages/59/8e/b97042ae92c59f40be0c989df090027377ba53f2d6cef73c9ca7685c26a6/coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7", size = 239555, upload-time = "2025-05-23T11:39:42.3Z" }, 241 | { url = "https://files.pythonhosted.org/packages/47/35/b8893e682d6e96b1db2af5997fc13ef62219426fb17259d6844c693c5e00/coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3", size = 237768, upload-time = "2025-05-23T11:39:44.069Z" }, 242 | { url = "https://files.pythonhosted.org/packages/03/6c/023b0b9a764cb52d6243a4591dcb53c4caf4d7340445113a1f452bb80591/coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7", size = 238757, upload-time = "2025-05-23T11:39:46.195Z" }, 243 | { url = "https://files.pythonhosted.org/packages/03/ed/3af7e4d721bd61a8df7de6de9e8a4271e67f3d9e086454558fd9f48eb4f6/coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a", size = 214166, upload-time = "2025-05-23T11:39:47.934Z" }, 244 | { url = "https://files.pythonhosted.org/packages/9d/30/ee774b626773750dc6128354884652507df3c59d6aa8431526107e595227/coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e", size = 215050, upload-time = "2025-05-23T11:39:50.252Z" }, 245 | { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, 246 | { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, 247 | ] 248 | 249 | [package.optional-dependencies] 250 | toml = [ 251 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 252 | ] 253 | 254 | [[package]] 255 | name = "devtools" 256 | version = "0.12.2" 257 | source = { registry = "https://pypi.org/simple" } 258 | dependencies = [ 259 | { name = "asttokens" }, 260 | { name = "executing" }, 261 | { name = "pygments" }, 262 | ] 263 | sdist = { url = "https://files.pythonhosted.org/packages/84/75/b78198620640d394bc435c17bb49db18419afdd6cfa3ed8bcfe14034ec80/devtools-0.12.2.tar.gz", hash = "sha256:efceab184cb35e3a11fa8e602cc4fadacaa2e859e920fc6f87bf130b69885507", size = 75005, upload-time = "2023-09-03T16:57:00.679Z" } 264 | wheels = [ 265 | { url = "https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl", hash = "sha256:c366e3de1df4cdd635f1ad8cbcd3af01a384d7abda71900e68d43b04eb6aaca7", size = 19411, upload-time = "2023-09-03T16:56:59.049Z" }, 266 | ] 267 | 268 | [[package]] 269 | name = "dirty-equals" 270 | version = "0.9.0" 271 | source = { registry = "https://pypi.org/simple" } 272 | sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412, upload-time = "2025-01-11T23:23:40.491Z" } 273 | wheels = [ 274 | { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, 275 | ] 276 | 277 | [[package]] 278 | name = "eval-type-backport" 279 | version = "0.2.2" 280 | source = { registry = "https://pypi.org/simple" } 281 | sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079, upload-time = "2024-12-21T20:09:46.005Z" } 282 | wheels = [ 283 | { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" }, 284 | ] 285 | 286 | [[package]] 287 | name = "exceptiongroup" 288 | version = "1.3.0" 289 | source = { registry = "https://pypi.org/simple" } 290 | dependencies = [ 291 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 292 | ] 293 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } 294 | wheels = [ 295 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, 296 | ] 297 | 298 | [[package]] 299 | name = "executing" 300 | version = "2.2.0" 301 | source = { registry = "https://pypi.org/simple" } 302 | sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } 303 | wheels = [ 304 | { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, 305 | ] 306 | 307 | [[package]] 308 | name = "h11" 309 | version = "0.16.0" 310 | source = { registry = "https://pypi.org/simple" } 311 | 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" } 312 | wheels = [ 313 | { 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" }, 314 | ] 315 | 316 | [[package]] 317 | name = "httpcore" 318 | version = "1.0.9" 319 | source = { registry = "https://pypi.org/simple" } 320 | dependencies = [ 321 | { name = "certifi" }, 322 | { name = "h11" }, 323 | ] 324 | 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" } 325 | wheels = [ 326 | { 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" }, 327 | ] 328 | 329 | [[package]] 330 | name = "httpx" 331 | version = "0.28.1" 332 | source = { registry = "https://pypi.org/simple" } 333 | dependencies = [ 334 | { name = "anyio" }, 335 | { name = "certifi" }, 336 | { name = "httpcore" }, 337 | { name = "idna" }, 338 | ] 339 | 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" } 340 | wheels = [ 341 | { 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" }, 342 | ] 343 | 344 | [[package]] 345 | name = "idna" 346 | version = "3.10" 347 | source = { registry = "https://pypi.org/simple" } 348 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } 349 | wheels = [ 350 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, 351 | ] 352 | 353 | [[package]] 354 | name = "iniconfig" 355 | version = "2.1.0" 356 | source = { registry = "https://pypi.org/simple" } 357 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 358 | wheels = [ 359 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 360 | ] 361 | 362 | [[package]] 363 | name = "inline-snapshot" 364 | version = "0.23.2" 365 | source = { registry = "https://pypi.org/simple" } 366 | dependencies = [ 367 | { name = "asttokens" }, 368 | { name = "executing" }, 369 | { name = "pytest" }, 370 | { name = "rich" }, 371 | { name = "tomli", marker = "python_full_version < '3.11'" }, 372 | ] 373 | sdist = { url = "https://files.pythonhosted.org/packages/e9/51/230163dedc58218b02421c6cc87aaf797451e244ed7756c0283471927ae2/inline_snapshot-0.23.2.tar.gz", hash = "sha256:440060e090db0da98bd1dea5d9c346291a0c7388213ff9437411ed59885a956d", size = 260704, upload-time = "2025-05-28T11:00:47.997Z" } 374 | wheels = [ 375 | { url = "https://files.pythonhosted.org/packages/cf/c8/ba2f735dd8a7fdd7199a92a9efdfc23e12d7a835b086bbba22a9a298debe/inline_snapshot-0.23.2-py3-none-any.whl", hash = "sha256:b6e32541d0ba116f5a0f4cca944729fa1095f6c08e190d531ab339b624654576", size = 50635, upload-time = "2025-05-28T11:00:45.976Z" }, 376 | ] 377 | 378 | [package.optional-dependencies] 379 | black = [ 380 | { name = "black" }, 381 | ] 382 | 383 | [[package]] 384 | name = "markdown-it-py" 385 | version = "3.0.0" 386 | source = { registry = "https://pypi.org/simple" } 387 | dependencies = [ 388 | { name = "mdurl" }, 389 | ] 390 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } 391 | wheels = [ 392 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, 393 | ] 394 | 395 | [[package]] 396 | name = "mdurl" 397 | version = "0.1.2" 398 | source = { registry = "https://pypi.org/simple" } 399 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 400 | wheels = [ 401 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 402 | ] 403 | 404 | [[package]] 405 | name = "mypy-extensions" 406 | version = "1.1.0" 407 | source = { registry = "https://pypi.org/simple" } 408 | sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } 409 | wheels = [ 410 | { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, 411 | ] 412 | 413 | [[package]] 414 | name = "nodeenv" 415 | version = "1.9.1" 416 | source = { registry = "https://pypi.org/simple" } 417 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } 418 | wheels = [ 419 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, 420 | ] 421 | 422 | [[package]] 423 | name = "packaging" 424 | version = "25.0" 425 | source = { registry = "https://pypi.org/simple" } 426 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 427 | wheels = [ 428 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 429 | ] 430 | 431 | [[package]] 432 | name = "pathspec" 433 | version = "0.12.1" 434 | source = { registry = "https://pypi.org/simple" } 435 | sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } 436 | wheels = [ 437 | { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, 438 | ] 439 | 440 | [[package]] 441 | name = "platformdirs" 442 | version = "4.3.8" 443 | source = { registry = "https://pypi.org/simple" } 444 | sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } 445 | wheels = [ 446 | { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, 447 | ] 448 | 449 | [[package]] 450 | name = "pluggy" 451 | version = "1.6.0" 452 | source = { registry = "https://pypi.org/simple" } 453 | 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" } 454 | wheels = [ 455 | { 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" }, 456 | ] 457 | 458 | [[package]] 459 | name = "pydantic" 460 | version = "2.11.5" 461 | source = { registry = "https://pypi.org/simple" } 462 | dependencies = [ 463 | { name = "annotated-types" }, 464 | { name = "pydantic-core" }, 465 | { name = "typing-extensions" }, 466 | { name = "typing-inspection" }, 467 | ] 468 | sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } 469 | wheels = [ 470 | { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, 471 | ] 472 | 473 | [[package]] 474 | name = "pydantic-core" 475 | version = "2.33.2" 476 | source = { registry = "https://pypi.org/simple" } 477 | dependencies = [ 478 | { name = "typing-extensions" }, 479 | ] 480 | sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } 481 | wheels = [ 482 | { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, 483 | { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, 484 | { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, 485 | { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, 486 | { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, 487 | { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, 488 | { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, 489 | { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, 490 | { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, 491 | { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, 492 | { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, 493 | { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, 494 | { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, 495 | { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, 496 | { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, 497 | { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, 498 | { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, 499 | { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, 500 | { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, 501 | { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, 502 | { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, 503 | { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, 504 | { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, 505 | { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, 506 | { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, 507 | { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, 508 | { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, 509 | { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, 510 | { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, 511 | { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, 512 | { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, 513 | { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, 514 | { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, 515 | { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, 516 | { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, 517 | { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, 518 | { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, 519 | { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, 520 | { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, 521 | { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, 522 | { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, 523 | { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, 524 | { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, 525 | { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, 526 | { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, 527 | { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, 528 | { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, 529 | { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, 530 | { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, 531 | { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, 532 | { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, 533 | { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, 534 | { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, 535 | { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, 536 | { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, 537 | { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, 538 | { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, 539 | { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, 540 | { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, 541 | { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, 542 | { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, 543 | { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, 544 | { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, 545 | { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, 546 | { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, 547 | { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, 548 | { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, 549 | { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, 550 | { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, 551 | { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, 552 | { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, 553 | { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, 554 | { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, 555 | { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, 556 | { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, 557 | { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, 558 | { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, 559 | { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, 560 | { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, 561 | { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, 562 | { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, 563 | { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, 564 | { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, 565 | { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, 566 | { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, 567 | { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, 568 | { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, 569 | { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, 570 | { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, 571 | { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, 572 | { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, 573 | { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, 574 | { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, 575 | { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, 576 | { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, 577 | { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, 578 | { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, 579 | { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, 580 | ] 581 | 582 | [[package]] 583 | name = "pygments" 584 | version = "2.19.1" 585 | source = { registry = "https://pypi.org/simple" } 586 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } 587 | wheels = [ 588 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, 589 | ] 590 | 591 | [[package]] 592 | name = "pyright" 593 | version = "1.1.398" 594 | source = { registry = "https://pypi.org/simple" } 595 | dependencies = [ 596 | { name = "nodeenv" }, 597 | { name = "typing-extensions" }, 598 | ] 599 | sdist = { url = "https://files.pythonhosted.org/packages/24/d6/48740f1d029e9fc4194880d1ad03dcf0ba3a8f802e0e166b8f63350b3584/pyright-1.1.398.tar.gz", hash = "sha256:357a13edd9be8082dc73be51190913e475fa41a6efb6ec0d4b7aab3bc11638d8", size = 3892675, upload-time = "2025-03-26T10:06:06.063Z" } 600 | wheels = [ 601 | { url = "https://files.pythonhosted.org/packages/58/e0/5283593f61b3c525d6d7e94cfb6b3ded20b3df66e953acaf7bb4f23b3f6e/pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753", size = 5780235, upload-time = "2025-03-26T10:06:03.994Z" }, 602 | ] 603 | 604 | [[package]] 605 | name = "pytest" 606 | version = "8.4.0" 607 | source = { registry = "https://pypi.org/simple" } 608 | dependencies = [ 609 | { name = "colorama", marker = "sys_platform == 'win32'" }, 610 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 611 | { name = "iniconfig" }, 612 | { name = "packaging" }, 613 | { name = "pluggy" }, 614 | { name = "pygments" }, 615 | { name = "tomli", marker = "python_full_version < '3.11'" }, 616 | ] 617 | sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } 618 | wheels = [ 619 | { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, 620 | ] 621 | 622 | [[package]] 623 | name = "pytest-pretty" 624 | version = "1.3.0" 625 | source = { registry = "https://pypi.org/simple" } 626 | dependencies = [ 627 | { name = "pytest" }, 628 | { name = "rich" }, 629 | ] 630 | sdist = { url = "https://files.pythonhosted.org/packages/ba/d7/c699e0be5401fe9ccad484562f0af9350b4e48c05acf39fb3dab1932128f/pytest_pretty-1.3.0.tar.gz", hash = "sha256:97e9921be40f003e40ae78db078d4a0c1ea42bf73418097b5077970c2cc43bf3", size = 219297, upload-time = "2025-06-04T12:54:37.322Z" } 631 | wheels = [ 632 | { url = "https://files.pythonhosted.org/packages/ab/85/2f97a1b65178b0f11c9c77c35417a4cc5b99a80db90dad4734a129844ea5/pytest_pretty-1.3.0-py3-none-any.whl", hash = "sha256:074b9d5783cef9571494543de07e768a4dda92a3e85118d6c7458c67297159b7", size = 5620, upload-time = "2025-06-04T12:54:36.229Z" }, 633 | ] 634 | 635 | [[package]] 636 | name = "rich" 637 | version = "14.0.0" 638 | source = { registry = "https://pypi.org/simple" } 639 | dependencies = [ 640 | { name = "markdown-it-py" }, 641 | { name = "pygments" }, 642 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 643 | ] 644 | sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } 645 | wheels = [ 646 | { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, 647 | ] 648 | 649 | [[package]] 650 | name = "ruff" 651 | version = "0.11.11" 652 | source = { registry = "https://pypi.org/simple" } 653 | sdist = { url = "https://files.pythonhosted.org/packages/b2/53/ae4857030d59286924a8bdb30d213d6ff22d8f0957e738d0289990091dd8/ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d", size = 4186707, upload-time = "2025-05-22T19:19:34.363Z" } 654 | wheels = [ 655 | { url = "https://files.pythonhosted.org/packages/b1/14/f2326676197bab099e2a24473158c21656fbf6a207c65f596ae15acb32b9/ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092", size = 10229049, upload-time = "2025-05-22T19:18:45.516Z" }, 656 | { url = "https://files.pythonhosted.org/packages/9a/f3/bff7c92dd66c959e711688b2e0768e486bbca46b2f35ac319bb6cce04447/ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4", size = 11053601, upload-time = "2025-05-22T19:18:49.269Z" }, 657 | { url = "https://files.pythonhosted.org/packages/e2/38/8e1a3efd0ef9d8259346f986b77de0f62c7a5ff4a76563b6b39b68f793b9/ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd", size = 10367421, upload-time = "2025-05-22T19:18:51.754Z" }, 658 | { url = "https://files.pythonhosted.org/packages/b4/50/557ad9dd4fb9d0bf524ec83a090a3932d284d1a8b48b5906b13b72800e5f/ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6", size = 10581980, upload-time = "2025-05-22T19:18:54.011Z" }, 659 | { url = "https://files.pythonhosted.org/packages/c4/b2/e2ed82d6e2739ece94f1bdbbd1d81b712d3cdaf69f0a1d1f1a116b33f9ad/ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4", size = 10089241, upload-time = "2025-05-22T19:18:56.041Z" }, 660 | { url = "https://files.pythonhosted.org/packages/3d/9f/b4539f037a5302c450d7c695c82f80e98e48d0d667ecc250e6bdeb49b5c3/ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac", size = 11699398, upload-time = "2025-05-22T19:18:58.248Z" }, 661 | { url = "https://files.pythonhosted.org/packages/61/fb/32e029d2c0b17df65e6eaa5ce7aea5fbeaed22dddd9fcfbbf5fe37c6e44e/ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709", size = 12427955, upload-time = "2025-05-22T19:19:00.981Z" }, 662 | { url = "https://files.pythonhosted.org/packages/6e/e3/160488dbb11f18c8121cfd588e38095ba779ae208292765972f7732bfd95/ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8", size = 12069803, upload-time = "2025-05-22T19:19:03.258Z" }, 663 | { url = "https://files.pythonhosted.org/packages/ff/16/3b006a875f84b3d0bff24bef26b8b3591454903f6f754b3f0a318589dcc3/ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b", size = 11242630, upload-time = "2025-05-22T19:19:05.871Z" }, 664 | { url = "https://files.pythonhosted.org/packages/65/0d/0338bb8ac0b97175c2d533e9c8cdc127166de7eb16d028a43c5ab9e75abd/ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875", size = 11507310, upload-time = "2025-05-22T19:19:08.584Z" }, 665 | { url = "https://files.pythonhosted.org/packages/6f/bf/d7130eb26174ce9b02348b9f86d5874eafbf9f68e5152e15e8e0a392e4a3/ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1", size = 10441144, upload-time = "2025-05-22T19:19:13.621Z" }, 666 | { url = "https://files.pythonhosted.org/packages/b3/f3/4be2453b258c092ff7b1761987cf0749e70ca1340cd1bfb4def08a70e8d8/ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81", size = 10081987, upload-time = "2025-05-22T19:19:15.821Z" }, 667 | { url = "https://files.pythonhosted.org/packages/6c/6e/dfa4d2030c5b5c13db158219f2ec67bf333e8a7748dccf34cfa2a6ab9ebc/ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639", size = 11073922, upload-time = "2025-05-22T19:19:18.104Z" }, 668 | { url = "https://files.pythonhosted.org/packages/ff/f4/f7b0b0c3d32b593a20ed8010fa2c1a01f2ce91e79dda6119fcc51d26c67b/ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345", size = 11568537, upload-time = "2025-05-22T19:19:20.889Z" }, 669 | { url = "https://files.pythonhosted.org/packages/d2/46/0e892064d0adc18bcc81deed9aaa9942a27fd2cd9b1b7791111ce468c25f/ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112", size = 10536492, upload-time = "2025-05-22T19:19:23.642Z" }, 670 | { url = "https://files.pythonhosted.org/packages/1b/d9/232e79459850b9f327e9f1dc9c047a2a38a6f9689e1ec30024841fc4416c/ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f", size = 11612562, upload-time = "2025-05-22T19:19:27.013Z" }, 671 | { url = "https://files.pythonhosted.org/packages/ce/eb/09c132cff3cc30b2e7244191dcce69437352d6d6709c0adf374f3e6f476e/ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b", size = 10735951, upload-time = "2025-05-22T19:19:30.043Z" }, 672 | ] 673 | 674 | [[package]] 675 | name = "six" 676 | version = "1.17.0" 677 | source = { registry = "https://pypi.org/simple" } 678 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } 679 | wheels = [ 680 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, 681 | ] 682 | 683 | [[package]] 684 | name = "sniffio" 685 | version = "1.3.1" 686 | source = { registry = "https://pypi.org/simple" } 687 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } 688 | wheels = [ 689 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, 690 | ] 691 | 692 | [[package]] 693 | name = "tomli" 694 | version = "2.2.1" 695 | source = { registry = "https://pypi.org/simple" } 696 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } 697 | wheels = [ 698 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, 699 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, 700 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, 701 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, 702 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, 703 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, 704 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, 705 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, 706 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, 707 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, 708 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, 709 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, 710 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, 711 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, 712 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, 713 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, 714 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, 715 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, 716 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, 717 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, 718 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, 719 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, 720 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, 721 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, 722 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, 723 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, 724 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, 725 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, 726 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, 727 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, 728 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, 729 | ] 730 | 731 | [[package]] 732 | name = "typing-extensions" 733 | version = "4.13.2" 734 | source = { registry = "https://pypi.org/simple" } 735 | sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } 736 | wheels = [ 737 | { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, 738 | ] 739 | 740 | [[package]] 741 | name = "typing-inspection" 742 | version = "0.4.1" 743 | source = { registry = "https://pypi.org/simple" } 744 | dependencies = [ 745 | { name = "typing-extensions" }, 746 | ] 747 | sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } 748 | wheels = [ 749 | { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, 750 | ] 751 | --------------------------------------------------------------------------------