From b5a23e5a2ac029526e4af805f983cbdea95c8504 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Sun, 29 Sep 2024 02:42:09 -0700
Subject: [PATCH 01/29] Add .vscode folder to .gitignore
---
.gitignore | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.gitignore b/.gitignore
index 3f65bf3..8a52af8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+.vscode/
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
From c35e6146fd4c164fb761d6db9a00d68359060d88 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Sun, 29 Sep 2024 03:06:35 -0700
Subject: [PATCH 02/29] New Base implementation
---
README.md | 12 +-
examples/__init__.py | 0
examples/basic_usage.py | 31 +-
pyproject.toml | 5 +-
src/contiguity_base/__init__.py | 9 +-
src/contiguity_base/_auth.py | 23 ++
src/contiguity_base/_client.py | 46 +++
src/contiguity_base/base.py | 512 ++++++++++++++++++++++++--------
uv.lock | 333 ++++++++++++++++-----
9 files changed, 741 insertions(+), 230 deletions(-)
create mode 100644 examples/__init__.py
create mode 100644 src/contiguity_base/_auth.py
create mode 100644 src/contiguity_base/_client.py
diff --git a/README.md b/README.md
index 2a3a6b3..9086412 100644
--- a/README.md
+++ b/README.md
@@ -23,13 +23,13 @@ db = connect("your-api-key", "your-project-id")
You can get an API key by fetching it in the [dashboard](https://base.contiguity.co), and a project ID is given to you when creating a project.
##
For those moving from Deta Space
-Contiguity Base is a one-to-one replacement for the old Deta Base API, Deta Base JavaScript SDK, Deta Base Python SDK, and Deta Base Go SDK. The only thing that has changed is initialization.
+Contiguity Base is a one-to-one replacement for the old Deta Base API, Deta Base JavaScript SDK, Deta Base Python SDK, and Deta Base Go SDK. The only thing that has changed is initialization.
Instead of `deta = Deta(project_key)`, you'll use `db = connect(api_key, project_id)`
The rest stays the same, because at Contiguity, we think it's crazy for a cloud provider to give you 45 days to move dozens of apps from their proprietary database.
-If you're transitioning from Deta Space to Contiguity, welcome!
+If you're transitioning from Deta Space to Contiguity, welcome!
## Creating your first "base" 📊
@@ -129,7 +129,7 @@ results = my_base.fetch({
```python
{
- "age": 22,
+ "age": 22,
"name": "Sarah"
}
```
@@ -137,7 +137,7 @@ results = my_base.fetch({
- **Hierarchical**
```python
{
- "user.profile.age": 22,
+ "user.profile.age": 22,
"user.profile.name": "Sarah"
}
```
@@ -152,8 +152,8 @@ results = my_base.fetch({
- **Nested Object**
```python
{
- "time": {
- "day": "Tuesday",
+ "time": {
+ "day": "Tuesday",
"hour": "08:00"
}
}
diff --git a/examples/__init__.py b/examples/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
index 0802e0f..0865947 100644
--- a/examples/basic_usage.py
+++ b/examples/basic_usage.py
@@ -1,44 +1,45 @@
-from contiguity_base import connect
+# ruff: noqa: T201, BLE001, ANN401
+from typing import Any
-# Initialize the Contiguity client
-db = connect("api-key", "project-id")
+from contiguity_base import Base
# Create a Base instance
-my_base = db.Base("randomBase9000")
+db = Base("randomBase9000")
+
# Helper function to print results
-def print_result(operation, result):
+def print_result(operation: str, result: Any) -> None:
print(f"{operation} result: {result}")
if result is None:
print(f"Warning: {operation} operation failed!")
+
# Put an item with a specific key
-result1 = my_base.put({"value": "Hello world!"}, "my-key-py")
+result1 = db.put({"key": "my-key-py", "value": "Hello world!"})
print_result("Put", result1)
# Insert an item with a specific key
-result2 = my_base.insert({"name": "John Doe", "age": 30}, "john-doe-py")
+result2 = db.insert({"key": "john-doe-py", "name": "John Doe", "age": 30})
print_result("Insert", result2)
# Insert an item with a specific key and expireIn
-result3 = my_base.insert({"name": "Jane Doe", "age": 28}, "jane-doe-py", expire_in=3600)
+result3 = db.insert({"key": "jane-doe-py", "name": "Jane Doe", "age": 28}, expire_in=3600)
print_result("Insert with expireIn", result3)
# Get an item
-get_result = my_base.get("john-doe-py")
+get_result = db.get("john-doe-py")
print_result("Get", get_result)
# Update an item
-update_result = my_base.update({"age": 31}, "john-doe-py")
+update_result = db.update({"age": 31}, key="john-doe-py")
print_result("Update", update_result)
# Fetch items
try:
- fetch_result = my_base.fetch({"age": {"$gt": 25}}, limit=10)
+ fetch_result = db.fetch({"age": {"$gt": 25}}, limit=10)
print_result("Fetch", fetch_result)
-except Exception as e:
- print(f"Fetch operation failed: {str(e)}")
+except Exception as exc:
+ print(f"Fetch operation failed: {exc}")
# Delete an item
-delete_result = my_base.delete("jane-doe-py")
-print_result("Delete", delete_result)
\ No newline at end of file
+delete_result = db.delete("jane-doe-py")
diff --git a/pyproject.toml b/pyproject.toml
index 9520edb..feaa287 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,9 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "requests>=2.25.1,<3.0.0",
+ "httpx>=0.27.2",
+ "pydantic>=2.9.0,<3.0.0",
+ "typing-extensions>=4.12.2,<5.0.0",
]
[project.urls]
@@ -46,6 +48,7 @@ version = {attr = "contiguity_base.__version__"}
[tool.uv]
dev-dependencies = [
"pre-commit~=3.8.0",
+ "pytest~=8.3.3",
]
[tool.ruff]
diff --git a/src/contiguity_base/__init__.py b/src/contiguity_base/__init__.py
index 99993ab..59eea52 100644
--- a/src/contiguity_base/__init__.py
+++ b/src/contiguity_base/__init__.py
@@ -1,9 +1,10 @@
-from .base import Base, Contiguity, Util, connect
+from .base import Base, BaseItem, FetchResponse, ItemConflictError, ItemNotFoundError
__all__ = (
"Base",
- "Contiguity",
- "Util",
- "connect",
+ "BaseItem",
+ "FetchResponse",
+ "ItemConflictError",
+ "ItemNotFoundError",
)
__version__ = "1.0.0"
diff --git a/src/contiguity_base/_auth.py b/src/contiguity_base/_auth.py
new file mode 100644
index 0000000..a390e37
--- /dev/null
+++ b/src/contiguity_base/_auth.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import os
+
+
+def _get_env_var(var_name: str, friendly_name: str | None = None) -> str:
+ value = os.getenv(var_name, "")
+ if not value:
+ msg = f"no {friendly_name or var_name} provided"
+ raise ValueError(msg)
+ return value
+
+
+def get_contiguity_token() -> str:
+ return _get_env_var("CONTIGUITY_TOKEN", "Contiguity token")
+
+
+def get_base_token() -> str:
+ return _get_env_var("CONTIGUITY_BASE_TOKEN", "Base token")
+
+
+def get_project_id() -> str:
+ return _get_env_var("CONTIGUITY_PROJECT_ID", "Project ID")
diff --git a/src/contiguity_base/_client.py b/src/contiguity_base/_client.py
new file mode 100644
index 0000000..fc31f71
--- /dev/null
+++ b/src/contiguity_base/_client.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from httpx import AsyncClient as HttpxAsyncClient
+from httpx import Client as HttpxClient
+
+from contiguity_base._auth import get_contiguity_token
+
+
+class Client(HttpxClient):
+ def __init__(
+ self: Client,
+ *,
+ base_url: str = "https://api.contiguity.co",
+ api_key: str | None = None,
+ timeout: int = 5,
+ ) -> None:
+ if not api_key:
+ api_key = get_contiguity_token()
+ super().__init__(
+ headers={
+ "Content-Type": "application/json",
+ "X-API-Key": api_key,
+ },
+ timeout=timeout,
+ base_url=base_url,
+ )
+
+
+class AsyncClient(HttpxAsyncClient):
+ def __init__(
+ self: AsyncClient,
+ *,
+ base_url: str = "https://api.contiguity.co",
+ api_key: str | None = None,
+ timeout: int = 5,
+ ) -> None:
+ if not api_key:
+ api_key = get_contiguity_token()
+ super().__init__(
+ headers={
+ "Content-Type": "application/json",
+ "X-API-Key": api_key,
+ },
+ timeout=timeout,
+ base_url=base_url,
+ )
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 06b0f1e..7b8a8f0 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -1,152 +1,416 @@
-import requests
-from typing import Union, List, Dict, Any, Optional
-from datetime import datetime
+# ruff: noqa: TD003, FIX002, ERA001, T201
+# Remove above ignores when issues are fixed.
+# TODO @lemonyte: todo list.
+# - [ ] custom json encoder support
+# - [ ] new docstrings
+# - [ ] proper tests
+# - [ ] add async
+# - [ ] add drive support
+# - [ ] merge into main sdk
+from __future__ import annotations
-class Util:
- @staticmethod
- def increment(value: int = 1) -> Dict[str, Any]:
- return {"__op": "increment", "value": value}
+import json
+import os
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timedelta, timezone
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Generic, TypeVar, Union, overload
+from urllib.parse import quote
+from warnings import warn
- @staticmethod
- def append(value: Any) -> Dict[str, Any]:
- return {"__op": "append", "value": value}
+from pydantic import BaseModel
+from pydantic import JsonValue as DataType
+from typing_extensions import deprecated
- @staticmethod
- def prepend(value: Any) -> Dict[str, Any]:
- return {"__op": "prepend", "value": value}
+from contiguity_base._auth import get_base_token, get_project_id
+from contiguity_base._client import Client
- @staticmethod
- def trim() -> Dict[str, str]:
- return {"__op": "trim"}
+if TYPE_CHECKING:
+ from httpx import Response as HttpxResponse
+ from typing_extensions import Self
+TimestampType = Union[int, datetime]
+QueryType = Union[DataType, list[DataType]]
-class Base:
- def __init__(self, db: 'Contiguity', name: str):
- self.db = db
- self.name = name
- self.util = Util()
+ItemType = Union[Mapping[str, DataType], BaseModel]
+ItemT = TypeVar("ItemT", bound=ItemType)
+DefaultItemT = TypeVar("DefaultItemT", bound=ItemType)
- def _fetch(self, method: str, path: str, body: Optional[Dict] = None) -> Optional[Dict]:
- url = f"{self.db.base_url}/{self.db.project_id}/{self.name}{path}"
- headers = {
- "x-api-key": self.db.api_key,
- "Content-Type": "application/json",
- }
+ModelT = TypeVar("ModelT", bound=BaseModel)
+DataT = TypeVar("DataT", bound=DataType)
- if self.db.debug:
- print(f"Sending {method} request to: {url}")
-
- try:
- response = requests.request(
- method, url, headers=headers, json=body)
- response.raise_for_status()
- return response.json()
- except requests.exceptions.HTTPError as e:
- if e.response.status_code == 404:
- return None
- print(
- f"HTTP error! status: {e.response.status_code}, body: {e.response.text}")
- except requests.exceptions.RequestException as e:
- if self.db.debug:
- print(f"Request failed: {str(e)}")
- return None
-
- def put(self, data: Union[Dict, List[Dict]], key: Optional[str] = None, expire_in: Optional[int] = None, expire_at: Optional[Union[datetime, str]] = None) -> Optional[Dict]:
- path = "/items"
-
- if isinstance(data, dict):
- if key:
- data['key'] = key
- items = [data]
- elif isinstance(data, list):
- items = data
- else:
- raise ValueError(
- "Data must be either a dictionary or a list of dictionaries")
-
- request_body = {"items": items}
- if expire_in is not None:
- request_body["expireIn"] = expire_in
+class _Unset:
+ pass
- if expire_at is not None:
- request_body["expireAt"] = expire_at
- return self._fetch("PUT", path, request_body)
+_UNSET = _Unset()
- def get(self, key: str) -> Optional[Dict]:
- return self._fetch("GET", f"/items/{key}")
- def delete(self, key: str) -> Optional[Dict]:
- return self._fetch("DELETE", f"/items/{key}")
+class BaseItem(BaseModel):
+ key: str
- def update(self, updates: Dict[str, Any], key: str, expire_in: Optional[int] = None, expire_at: Optional[Union[datetime, str]] = None) -> Optional[Dict]:
- processed_updates = {
- field: (value if isinstance(value, dict) and "__op" in value else {"__op": "set", "value": value})
- for field, value in updates.items()
- }
-
- request_body = {"updates": processed_updates}
- if expire_in is not None:
- request_body["expireIn"] = expire_in
-
- if expire_at is not None:
- if isinstance(expire_at, datetime):
- request_body["expireAt"] = expire_at.isoformat()
- else:
- request_body["expireAt"] = expire_at
+class ItemConflictError(Exception):
+ pass
- return self._fetch("PATCH", f"/items/{key}", request_body)
- def fetch(self, query: Optional[Dict] = None, limit: Optional[int] = None, last: Optional[str] = None) -> Dict[str, Any]:
- query_params = {
- "query": query,
- "limit": limit,
- "last": last
- }
- query_params = {k: v for k, v in query_params.items() if v is not None}
+class ItemNotFoundError(Exception):
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"key '{key}' not found", *args)
- if self.db.debug:
- print(f"Fetch params sent to server: {query_params}")
- response = self._fetch("POST", "/query", query_params)
- return {
- "items": response.get("items", []),
- "last": response.get("last"),
- "count": response.get("count", 0)
- }
+class FetchResponse(BaseModel, Generic[ItemT]):
+ count: int = 0
+ last_key: Union[str, None] = None # noqa: UP007 Pydantic doesn't support `X | Y` syntax in Python 3.9.
+ items: list[ItemT] = []
- def insert(self, data: Dict, key: Optional[str] = None, expire_in: Optional[int] = None, expire_at: Optional[Union[datetime, str]] = None) -> Optional[Dict]:
- path = "/items"
-
- if key:
- data['key'] = key
+class _UpdateOperation:
+ def __init__(self: _UpdateOperation) -> None:
+ self.value = None
- request_body = {"item": data}
+ def as_dict(self: _UpdateOperation) -> dict[str, DataType]:
+ return {"__op": self.__class__.__name__.lower().replace("_", ""), "value": self.value}
- if expire_in is not None:
- request_body["expireIn"] = expire_in
-
- if expire_at is not None:
- if isinstance(expire_at, datetime):
- request_body["expireAt"] = expire_at.isoformat()
- else:
- request_body["expireAt"] = expire_at
- return self._fetch("POST", path, request_body)
+class _Trim(_UpdateOperation):
+ def as_dict(self: _UpdateOperation) -> dict[str, DataType]:
+ return {"__op": "trim"}
+
-class Contiguity:
- def __init__(self, api_key: str, project_id: str, debug: bool = False):
- self.api_key = api_key
- self.project_id = project_id
- self.base_url = "https://api.base.contiguity.co/v1"
- self.debug = debug
+class _Increment(_UpdateOperation):
+ def __init__(self: _Increment, value: int = 1, /) -> None:
+ self.value = value
- def Base(self, name: str) -> Base:
- return Base(self, name)
-def connect(api_key: str, project_id: str, debug: bool = False) -> Contiguity:
- return Contiguity(api_key, project_id, debug)
\ No newline at end of file
+class _Append(_UpdateOperation):
+ def __init__(self: _Append, value: DataType, /) -> None:
+ self.value = value
+ # TODO @lemonyte: The API does not support multi-value append and prepend yet.
+ # Uncomment this here and in _Prepend when it does.
+ # if not isinstance(value, Sequence):
+ # # Extra type hint because type checkers can be stupid sometimes.
+ # self.value: DataType = [value]
+
+
+class _Prepend(_UpdateOperation):
+ def __init__(self: _Prepend, value: DataType, /) -> None:
+ self.value = value
+ # if not isinstance(value, Sequence):
+ # self.value: DataType = [value]
+
+
+class _Updates:
+ @staticmethod
+ def trim() -> _Trim:
+ return _Trim()
+
+ @staticmethod
+ def increment(value: int = 1, /) -> _Increment:
+ return _Increment(value)
+
+ @staticmethod
+ def append(value: DataType, /) -> _Append:
+ return _Append(value)
+
+ @staticmethod
+ def prepend(value: DataType, /) -> _Prepend:
+ return _Prepend(value)
+
+
+class Base(Generic[ItemT]):
+ EXPIRES_ATTRIBUTE = "__expires"
+ PUT_LIMIT = 30
+
+ @overload
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] = Mapping[str, DataType],
+ base_token: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ @overload
+ @deprecated("The `project_key` parameter has been renamed to `base_token`.")
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] = Mapping[str, DataType],
+ project_key: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ def __init__( # noqa: PLR0913
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] = Mapping[str, DataType],
+ base_token: str | None = None,
+ project_key: str | None = None, # Deprecated.
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None:
+ if not name:
+ msg = f"invalid name '{name}'"
+ raise ValueError(msg)
+
+ self.name = name
+ self.item_type = item_type
+ self.base_token = base_token or project_key or get_base_token()
+ self.project_id = project_id or get_project_id()
+ self.host = host or os.getenv("CONTIGUITY_BASE_HOST") or "api.base.contiguity.co"
+ self.api_version = api_version
+ self.json_encoder = json_encoder
+ self.json_decoder = json_decoder
+ self.util = _Updates()
+ self._client = Client(
+ base_url=f"https://{self.host}/{api_version}/{self.project_id}/{self.name}",
+ api_key=self.base_token,
+ timeout=300,
+ )
+
+ def _data_as_item_type(self: Self, data: Mapping[str, DataType]) -> ItemT:
+ print("data:", data)
+ if issubclass(self.item_type, BaseModel):
+ return self.item_type.model_validate(data)
+ # TODO @lemonyte: support dicts as well as BaseModel subclasses.
+ # if isinstance(data, self.item_type.__args__[0]):
+ # return data
+ msg = f"failed to convert data to item type {self.item_type}"
+ raise ValueError(msg)
+
+ def _response_as_item_type(self: Self, response: HttpxResponse) -> ItemT | Sequence[ItemT]:
+ data = response.raise_for_status().json(cls=self.json_decoder)
+ if isinstance(data, Sequence):
+ return [self._data_as_item_type(item) for item in data]
+ return self._data_as_item_type(data)
+
+ def _insert_expires_attr(
+ self: Self,
+ item: ItemT | Mapping[str, DataType],
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> dict[str, DataType]:
+ if expire_in and expire_at:
+ msg = "cannot use both expire_in and expire_at"
+ raise ValueError(msg)
+
+ item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
+
+ if not expire_in and not expire_at:
+ return item_dict
+
+ if expire_in:
+ expire_at = datetime.now(tz=timezone.utc) + timedelta(seconds=expire_in)
+
+ if isinstance(expire_at, datetime):
+ expire_at = int(expire_at.replace(microsecond=0).timestamp())
+
+ if not isinstance(expire_at, int):
+ msg = "expire_at should be a datetime or int"
+ raise TypeError(msg)
+
+ item_dict[self.EXPIRES_ATTRIBUTE] = expire_at
+ return item_dict
+
+ @overload
+ def get(self: Self, key: str, /) -> ItemT | None: ...
+
+ @overload
+ def get(self: Self, key: str, default: DefaultItemT, /) -> ItemT | DefaultItemT: ...
+
+ def get(self: Self, key: str, default: DefaultItemT | _Unset = _UNSET, /) -> ItemT | DefaultItemT | None:
+ if not key:
+ msg = f"invalid key '{key}'"
+ raise ValueError(msg)
+
+ key = quote(key, safe="")
+ response = self._client.get(f"/items/{key}")
+
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ if not isinstance(default, _Unset):
+ return default
+ msg = (
+ "ItemNotFoundError will be raised in the future."
+ " To receive None for non-existent keys, set default=None."
+ )
+ warn(DeprecationWarning(msg), stacklevel=2)
+ return None
+ # raise ItemNotFoundError(key)
+
+ # TODO @lemonyte: due to the API returning both a single item and a list of items,
+ # we need to check the response type.
+ # Remove when the API is fixed to always return a list of items.
+ returned_item = self._response_as_item_type(response)
+ if isinstance(returned_item, Sequence):
+ # this shouldn't happen, it's just here until the API is fixed
+ msg = "expected a single item, got a list of items"
+ raise TypeError(msg)
+ return returned_item
+
+ def delete(self: Self, key: str, /, *, ignore_missing: bool = False) -> None:
+ """Delete an item from the Base."""
+ if not key:
+ msg = f"invalid key '{key}'"
+ raise ValueError(msg)
+
+ key = quote(key, safe="")
+ response = self._client.delete(f"/items/{key}")
+ if response.status_code == HTTPStatus.NOT_FOUND and not ignore_missing:
+ raise ItemNotFoundError(key)
+ response.raise_for_status()
+
+ def insert(
+ self: Self,
+ item: ItemT,
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ item_dict = self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at)
+ response = self._client.post("/items", json={"item": item_dict})
+
+ if response.status_code == HTTPStatus.CONFLICT:
+ msg = f"item with key '{item_dict.get('key')}' already exists"
+ raise ItemConflictError(msg)
+
+ # TODO @lemonyte: due to the API returning both a single item and a list of items,
+ # we need to check the response type.
+ # Remove when the API is fixed to always return a list of items.
+ returned_item = self._response_as_item_type(response)
+ if isinstance(returned_item, Sequence):
+ # this shouldn't happen, it's just here until the API is fixed
+ msg = "expected a single item, got a list of items"
+ raise TypeError(msg)
+ return returned_item
+
+ def put(
+ self: Self,
+ *items: ItemT,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT | Sequence[ItemT]:
+ """store (put) an item in the database. Overrides an item if key already exists.
+ `key` could be provided as function argument or a field in the data dict.
+ If `key` is not provided, the server will generate a random 12 chars key.
+ """
+ if len(items) > self.PUT_LIMIT:
+ msg = f"cannot put more than {self.PUT_LIMIT} items at a time"
+ raise ValueError(msg)
+
+ item_dicts = [self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at) for item in items]
+ response = self._client.put("/items", json={"items": item_dicts})
+ return self._response_as_item_type(response)
+
+ @deprecated("This method will be removed in the future. You can pass multiple items to `put`.")
+ def put_many(
+ self: Self,
+ items: Sequence[ItemT],
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT | Sequence[ItemT]:
+ return self.put(*items, expire_in=expire_in, expire_at=expire_at)
+
+ def fetch(
+ self: Self,
+ query: QueryType | None = None,
+ /,
+ *,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> FetchResponse[ItemT]:
+ """fetch items from the database.
+ `query` is an optional filter or list of filters. Without filter, it will return the whole db.
+ """
+
+ payload = {
+ "limit": limit,
+ "last": last,
+ }
+
+ if query:
+ payload["query"] = query if isinstance(query, list) else [query]
+
+ response = self._client.post("/query", json=payload)
+ response_json = response.raise_for_status().json(cls=self.json_decoder)
+ return FetchResponse(
+ count=response_json.get("count", 0),
+ last_key=response_json.get("last", None),
+ items=[self._data_as_item_type(item) for item in response_json.get("items", [])],
+ )
+
+ def update(
+ self: Self,
+ updates: Mapping[str, DataType | _UpdateOperation],
+ /,
+ *,
+ key: str,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ """update an item in the database
+ `updates` specifies the attribute names and values to update,add or remove
+ `key` is the key of the item to be updated
+ """
+ if not key:
+ msg = f"invalid key '{key}'"
+ raise ValueError(msg)
+
+ payload = {
+ "updates": {
+ field: (value.as_dict() if isinstance(value, _UpdateOperation) else {"__op": "set", "value": value})
+ for field, value in updates.items()
+ },
+ }
+
+ # payload = self._insert_expires_attr(
+ # payload,
+ # expire_in=expire_in,
+ # expire_at=expire_at,
+ # )
+
+ # TODO @lemonyte: Remove when the API adds support for __expires.
+ if expire_in is not None:
+ payload["expireIn"] = expire_in # type: ignore[assignment]
+ if expire_at is not None:
+ if isinstance(expire_at, datetime):
+ payload["expireAt"] = expire_at.isoformat() # type: ignore[assignment]
+ else:
+ payload["expireAt"] = expire_at # type: ignore[assignment]
+
+ key = quote(key, safe="")
+ response = self._client.patch(f"/items/{key}", json=payload)
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ raise ItemNotFoundError(key)
+
+ # TODO @lemonyte: due to the API returning both a single item and a list of items,
+ # we need to check the response type.
+ # Remove when the API is fixed to always return a list of items.
+ returned_item = self._response_as_item_type(response)
+ if isinstance(returned_item, Sequence):
+ # this shouldn't happen, it's just here until the API is fixed
+ msg = "expected a single item, got a list of items"
+ raise TypeError(msg)
+ return returned_item
diff --git a/uv.lock b/uv.lock
index cf81c55..bf0d4ee 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,33 @@
version = 1
requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version < '3.13'",
+ "python_full_version >= '3.13'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "idna" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 },
+]
[[package]]
name = "certifi"
@@ -20,72 +48,12 @@ wheels = [
]
[[package]]
-name = "charset-normalizer"
-version = "3.3.2"
+name = "colorama"
+version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2b/61/095a0aa1a84d1481998b534177c8566fdc50bb1233ea9a0478cd3cc075bd/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", size = 194219 },
- { url = "https://files.pythonhosted.org/packages/cc/94/f7cf5e5134175de79ad2059edf2adce18e0685ebdb9227ff0139975d0e93/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", size = 122521 },
- { url = "https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", size = 120383 },
- { url = "https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", size = 138223 },
- { url = "https://files.pythonhosted.org/packages/05/8c/eb854996d5fef5e4f33ad56927ad053d04dc820e4a3d39023f35cad72617/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", size = 148101 },
- { url = "https://files.pythonhosted.org/packages/f6/93/bb6cbeec3bf9da9b2eba458c15966658d1daa8b982c642f81c93ad9b40e1/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", size = 140699 },
- { url = "https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", size = 142065 },
- { url = "https://files.pythonhosted.org/packages/3f/ba/3f5e7be00b215fa10e13d64b1f6237eb6ebea66676a41b2bcdd09fe74323/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", size = 144505 },
- { url = "https://files.pythonhosted.org/packages/33/c3/3b96a435c5109dd5b6adc8a59ba1d678b302a97938f032e3770cc84cd354/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", size = 139425 },
- { url = "https://files.pythonhosted.org/packages/43/05/3bf613e719efe68fb3a77f9c536a389f35b95d75424b96b426a47a45ef1d/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", size = 145287 },
- { url = "https://files.pythonhosted.org/packages/58/78/a0bc646900994df12e07b4ae5c713f2b3e5998f58b9d3720cce2aa45652f/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", size = 149929 },
- { url = "https://files.pythonhosted.org/packages/eb/5c/97d97248af4920bc68687d9c3b3c0f47c910e21a8ff80af4565a576bd2f0/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", size = 141605 },
- { url = "https://files.pythonhosted.org/packages/a8/31/47d018ef89f95b8aded95c589a77c072c55e94b50a41aa99c0a2008a45a4/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", size = 142646 },
- { url = "https://files.pythonhosted.org/packages/ae/d5/4fecf1d58bedb1340a50f165ba1c7ddc0400252d6832ff619c4568b36cc0/charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", size = 92846 },
- { url = "https://files.pythonhosted.org/packages/a2/a0/4af29e22cb5942488cf45630cbdd7cefd908768e69bdd90280842e4e8529/charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", size = 100343 },
- { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 },
- { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 },
- { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 },
- { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 },
- { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 },
- { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 },
- { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 },
- { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 },
- { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 },
- { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 },
- { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 },
- { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 },
- { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 },
- { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 },
- { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 },
- { url = "https://files.pythonhosted.org/packages/d1/b2/fcedc8255ec42afee97f9e6f0145c734bbe104aac28300214593eb326f1d/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", size = 192892 },
- { url = "https://files.pythonhosted.org/packages/2e/7d/2259318c202f3d17f3fe6438149b3b9e706d1070fe3fcbb28049730bb25c/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", size = 122213 },
- { url = "https://files.pythonhosted.org/packages/3a/52/9f9d17c3b54dc238de384c4cb5a2ef0e27985b42a0e5cc8e8a31d918d48d/charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", size = 119404 },
- { url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275 },
- { url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518 },
- { url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182 },
- { url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869 },
- { url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042 },
- { url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275 },
- { url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819 },
- { url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415 },
- { url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212 },
- { url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167 },
- { url = "https://files.pythonhosted.org/packages/ed/3a/a448bf035dce5da359daf9ae8a16b8a39623cc395a2ffb1620aa1bce62b0/charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", size = 93041 },
- { url = "https://files.pythonhosted.org/packages/b6/7c/8debebb4f90174074b827c63242c23851bdf00a532489fba57fef3416e40/charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", size = 100397 },
- { url = "https://files.pythonhosted.org/packages/f7/9d/bcf4a449a438ed6f19790eee543a86a740c77508fbc5ddab210ab3ba3a9a/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", size = 194198 },
- { url = "https://files.pythonhosted.org/packages/66/fe/c7d3da40a66a6bf2920cce0f436fa1f62ee28aaf92f412f0bf3b84c8ad6c/charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", size = 122494 },
- { url = "https://files.pythonhosted.org/packages/2a/9d/a6d15bd1e3e2914af5955c8eb15f4071997e7078419328fee93dfd497eb7/charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", size = 120393 },
- { url = "https://files.pythonhosted.org/packages/3d/85/5b7416b349609d20611a64718bed383b9251b5a601044550f0c8983b8900/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", size = 138331 },
- { url = "https://files.pythonhosted.org/packages/79/66/8946baa705c588521afe10b2d7967300e49380ded089a62d38537264aece/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", size = 148097 },
- { url = "https://files.pythonhosted.org/packages/44/80/b339237b4ce635b4af1c73742459eee5f97201bd92b2371c53e11958392e/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", size = 140711 },
- { url = "https://files.pythonhosted.org/packages/98/69/5d8751b4b670d623aa7a47bef061d69c279e9f922f6705147983aa76c3ce/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", size = 142251 },
- { url = "https://files.pythonhosted.org/packages/1f/8d/33c860a7032da5b93382cbe2873261f81467e7b37f4ed91e25fed62fd49b/charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", size = 144636 },
- { url = "https://files.pythonhosted.org/packages/c2/65/52aaf47b3dd616c11a19b1052ce7fa6321250a7a0b975f48d8c366733b9f/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", size = 139514 },
- { url = "https://files.pythonhosted.org/packages/51/fd/0ee5b1c2860bb3c60236d05b6e4ac240cf702b67471138571dad91bcfed8/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", size = 145528 },
- { url = "https://files.pythonhosted.org/packages/e1/9c/60729bf15dc82e3aaf5f71e81686e42e50715a1399770bcde1a9e43d09db/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", size = 149804 },
- { url = "https://files.pythonhosted.org/packages/53/cd/aa4b8a4d82eeceb872f83237b2d27e43e637cac9ffaef19a1321c3bafb67/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", size = 141708 },
- { url = "https://files.pythonhosted.org/packages/54/7f/cad0b328759630814fcf9d804bfabaf47776816ad4ef2e9938b7e1123d04/charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561", size = 142708 },
- { url = "https://files.pythonhosted.org/packages/c1/9d/254a2f1bcb0ce9acad838e94ed05ba71a7cb1e27affaa4d9e1ca3958cdb6/charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", size = 92830 },
- { url = "https://files.pythonhosted.org/packages/2f/0e/d7303ccae9735ff8ff01e36705ad6233ad2002962e8668a970fc000c5e1b/charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", size = 100376 },
- { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 },
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
@@ -93,19 +61,29 @@ name = "contiguity-base"
version = "1.0.0"
source = { editable = "." }
dependencies = [
- { name = "requests" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
+ { name = "pytest" },
]
[package.metadata]
-requires-dist = [{ name = "requests", specifier = ">=2.25.1,<3.0.0" }]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27.2" },
+ { name = "pydantic", specifier = ">=2.9.0,<3.0.0" },
+ { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
+]
[package.metadata.requires-dev]
-dev = [{ name = "pre-commit", specifier = "~=3.8.0" }]
+dev = [
+ { name = "pre-commit", specifier = "~=3.8.0" },
+ { name = "pytest", specifier = "~=8.3.3" },
+]
[[package]]
name = "distlib"
@@ -116,6 +94,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 },
]
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
[[package]]
name = "filelock"
version = "3.16.1"
@@ -125,6 +112,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 },
]
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/b0/5e8b8674f8d203335a62fdfcfa0d11ebe09e23613c3391033cbba35f7926/httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", size = 83234 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 },
+]
+
[[package]]
name = "identify"
version = "2.6.1"
@@ -143,6 +168,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
+]
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -152,6 +186,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
]
+[[package]]
+name = "packaging"
+version = "24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
+]
+
[[package]]
name = "platformdirs"
version = "4.3.6"
@@ -161,6 +204,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
]
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
[[package]]
name = "pre-commit"
version = "3.8.0"
@@ -177,6 +229,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 },
]
+[[package]]
+name = "pydantic"
+version = "2.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.23.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/8b/d3ae387f66277bd8104096d6ec0a145f4baa2966ebb2cad746c0920c9526/pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", size = 1867835 },
+ { url = "https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", size = 1776689 },
+ { url = "https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", size = 1800748 },
+ { url = "https://files.pythonhosted.org/packages/50/ab/891a7b0054bcc297fb02d44d05c50e68154e31788f2d9d41d0b72c89fdf7/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", size = 1806469 },
+ { url = "https://files.pythonhosted.org/packages/31/7c/6e3fa122075d78f277a8431c4c608f061881b76c2b7faca01d317ee39b5d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", size = 2002246 },
+ { url = "https://files.pythonhosted.org/packages/ad/6f/22d5692b7ab63fc4acbc74de6ff61d185804a83160adba5e6cc6068e1128/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", size = 2659404 },
+ { url = "https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", size = 2053940 },
+ { url = "https://files.pythonhosted.org/packages/91/75/984740c17f12c3ce18b5a2fcc4bdceb785cce7df1511a4ce89bca17c7e2d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", size = 1921437 },
+ { url = "https://files.pythonhosted.org/packages/a0/74/13c5f606b64d93f0721e7768cd3e8b2102164866c207b8cd6f90bb15d24f/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", size = 1966129 },
+ { url = "https://files.pythonhosted.org/packages/18/03/9c4aa5919457c7b57a016c1ab513b1a926ed9b2bb7915bf8e506bf65c34b/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", size = 2110908 },
+ { url = "https://files.pythonhosted.org/packages/92/2c/053d33f029c5dc65e5cf44ff03ceeefb7cce908f8f3cca9265e7f9b540c8/pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", size = 1735278 },
+ { url = "https://files.pythonhosted.org/packages/de/81/7dfe464eca78d76d31dd661b04b5f2036ec72ea8848dd87ab7375e185c23/pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", size = 1917453 },
+ { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 },
+ { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 },
+ { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 },
+ { url = "https://files.pythonhosted.org/packages/a9/8f/89c1405176903e567c5f99ec53387449e62f1121894aa9fc2c4fdc51a59b/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607", size = 1805307 },
+ { url = "https://files.pythonhosted.org/packages/d5/a5/1a194447d0da1ef492e3470680c66048fef56fc1f1a25cafbea4bc1d1c48/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", size = 2000663 },
+ { url = "https://files.pythonhosted.org/packages/13/a5/1df8541651de4455e7d587cf556201b4f7997191e110bca3b589218745a5/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", size = 2655941 },
+ { url = "https://files.pythonhosted.org/packages/44/31/a3899b5ce02c4316865e390107f145089876dff7e1dfc770a231d836aed8/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", size = 2052105 },
+ { url = "https://files.pythonhosted.org/packages/1b/aa/98e190f8745d5ec831f6d5449344c48c0627ac5fed4e5340a44b74878f8e/pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", size = 1919967 },
+ { url = "https://files.pythonhosted.org/packages/ae/35/b6e00b6abb2acfee3e8f85558c02a0822e9a8b2f2d812ea8b9079b118ba0/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", size = 1964291 },
+ { url = "https://files.pythonhosted.org/packages/13/46/7bee6d32b69191cd649bbbd2361af79c472d72cb29bb2024f0b6e350ba06/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", size = 2109666 },
+ { url = "https://files.pythonhosted.org/packages/39/ef/7b34f1b122a81b68ed0a7d0e564da9ccdc9a2924c8d6c6b5b11fa3a56970/pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", size = 1732940 },
+ { url = "https://files.pythonhosted.org/packages/2f/76/37b7e76c645843ff46c1d73e046207311ef298d3f7b2f7d8f6ac60113071/pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", size = 1916804 },
+ { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459 },
+ { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007 },
+ { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245 },
+ { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260 },
+ { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872 },
+ { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617 },
+ { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831 },
+ { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453 },
+ { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793 },
+ { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872 },
+ { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535 },
+ { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992 },
+ { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 },
+ { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 },
+ { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 },
+ { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 },
+ { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 },
+ { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 },
+ { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 },
+ { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 },
+ { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 },
+ { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 },
+ { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 },
+ { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 },
+ { url = "https://files.pythonhosted.org/packages/7a/04/2580b2deaae37b3e30fc30c54298be938b973990b23612d6b61c7bdd01c7/pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", size = 1868200 },
+ { url = "https://files.pythonhosted.org/packages/39/6e/e311bd0751505350f0cdcee3077841eb1f9253c5a1ddbad048cd9fbf7c6e/pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", size = 1749316 },
+ { url = "https://files.pythonhosted.org/packages/d0/b4/95b5eb47c6dc8692508c3ca04a1f8d6f0884c9dacb34cf3357595cbe73be/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", size = 1800880 },
+ { url = "https://files.pythonhosted.org/packages/da/79/41c4f817acd7f42d94cd1e16526c062a7b089f66faed4bd30852314d9a66/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", size = 1807077 },
+ { url = "https://files.pythonhosted.org/packages/fb/53/d13d1eb0a97d5c06cf7a225935d471e9c241afd389a333f40c703f214973/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", size = 2002859 },
+ { url = "https://files.pythonhosted.org/packages/53/7d/6b8a1eff453774b46cac8c849e99455b27167971a003212f668e94bc4c9c/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", size = 2661437 },
+ { url = "https://files.pythonhosted.org/packages/6c/ea/8820f57f0b46e6148ee42d8216b15e8fe3b360944284bbc705bf34fac888/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", size = 2054404 },
+ { url = "https://files.pythonhosted.org/packages/0f/36/d4ae869e473c3c7868e1cd1e2a1b9e13bce5cd1a7d287f6ac755a0b1575e/pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", size = 1921680 },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/eed5c65b80c4ac4494117e2101973b45fc655774ef647d17dde40a70f7d2/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", size = 1966093 },
+ { url = "https://files.pythonhosted.org/packages/e8/c8/1d42ce51d65e571ab53d466cae83434325a126811df7ce4861d9d97bee4b/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", size = 2111437 },
+ { url = "https://files.pythonhosted.org/packages/aa/c9/7fea9d13383c2ec6865919e09cffe44ab77e911eb281b53a4deaafd4c8e8/pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", size = 1735049 },
+ { url = "https://files.pythonhosted.org/packages/98/95/dd7045c4caa2b73d0bf3b989d66b23cfbb7a0ef14ce99db15677a000a953/pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", size = 1920180 },
+ { url = "https://files.pythonhosted.org/packages/13/a9/5d582eb3204464284611f636b55c0a7410d748ff338756323cb1ce721b96/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", size = 1857135 },
+ { url = "https://files.pythonhosted.org/packages/2c/57/faf36290933fe16717f97829eabfb1868182ac495f99cf0eda9f59687c9d/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", size = 1740583 },
+ { url = "https://files.pythonhosted.org/packages/91/7c/d99e3513dc191c4fec363aef1bf4c8af9125d8fa53af7cb97e8babef4e40/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", size = 1793637 },
+ { url = "https://files.pythonhosted.org/packages/29/18/812222b6d18c2d13eebbb0f7cdc170a408d9ced65794fdb86147c77e1982/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", size = 1941963 },
+ { url = "https://files.pythonhosted.org/packages/0f/36/c1f3642ac3f05e6bb4aec3ffc399fa3f84895d259cf5f0ce3054b7735c29/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", size = 1915332 },
+ { url = "https://files.pythonhosted.org/packages/f7/ca/9c0854829311fb446020ebb540ee22509731abad886d2859c855dd29b904/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", size = 1957926 },
+ { url = "https://files.pythonhosted.org/packages/c0/1c/7836b67c42d0cd4441fcd9fafbf6a027ad4b79b6559f80cf11f89fd83648/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", size = 2100342 },
+ { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 },
+ { url = "https://files.pythonhosted.org/packages/32/fd/ac9cdfaaa7cf2d32590b807d900612b39acb25e5527c3c7e482f0553025b/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", size = 1857850 },
+ { url = "https://files.pythonhosted.org/packages/08/fe/038f4b2bcae325ea643c8ad353191187a4c92a9c3b913b139289a6f2ef04/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", size = 1740265 },
+ { url = "https://files.pythonhosted.org/packages/51/14/b215c9c3cbd1edaaea23014d4b3304260823f712d3fdee52549b19b25d62/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", size = 1793912 },
+ { url = "https://files.pythonhosted.org/packages/62/de/2c3ad79b63ba564878cbce325be725929ba50089cd5156f89ea5155cb9b3/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", size = 1942870 },
+ { url = "https://files.pythonhosted.org/packages/cb/55/c222af19e4644c741b3f3fe4fd8bbb6b4cdca87d8a49258b61cf7826b19e/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", size = 1915610 },
+ { url = "https://files.pythonhosted.org/packages/c4/7a/9a8760692a6f76bb54bcd43f245ff3d8b603db695899bbc624099c00af80/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", size = 1958403 },
+ { url = "https://files.pythonhosted.org/packages/4c/91/9b03166feb914bb5698e2f6499e07c2617e2eebf69f9374d0358d7eb2009/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", size = 2101154 },
+ { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -231,27 +401,30 @@ wheels = [
]
[[package]]
-name = "requests"
-version = "2.32.3"
+name = "sniffio"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
+ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 },
]
[[package]]
-name = "urllib3"
-version = "2.2.3"
+name = "typing-extensions"
+version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
+ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
From 25e681690304df2deee6712ca57b5a952ae1a592 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Sun, 29 Sep 2024 03:07:04 -0700
Subject: [PATCH 03/29] Add preliminary tests
---
tests/__init__.py | 0
tests/test_base.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
create mode 100644 tests/__init__.py
create mode 100644 tests/test_base.py
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_base.py b/tests/test_base.py
new file mode 100644
index 0000000..ffe8c33
--- /dev/null
+++ b/tests/test_base.py
@@ -0,0 +1,83 @@
+# ruff: noqa: S101, PLR2004
+from collections.abc import Generator, Sequence
+from typing import Any
+
+import pytest
+from pydantic import BaseModel
+
+from contiguity_base.base import Base, FetchResponse, ItemConflictError
+
+
+class TestItem(BaseModel):
+ key: str
+ value: str
+
+
+@pytest.fixture
+def base() -> Generator[Base[TestItem], Any, None]:
+ base = Base("test_base", item_type=TestItem)
+ for item in base.fetch().items:
+ base.delete(item.key)
+ yield base
+ for item in base.fetch().items:
+ base.delete(item.key)
+
+
+def test_insert_item(base: Base[TestItem]) -> None:
+ item = TestItem(key="test_key", value="test_value")
+ inserted_item = base.insert(item)
+ assert inserted_item.key == item.key
+ assert inserted_item.value == item.value
+
+
+def test_insert_existing_item(base: Base[TestItem]) -> None:
+ item = TestItem(key="test_key", value="test_value")
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_get_item(base: Base[TestItem]) -> None:
+ item = TestItem(key="test_key", value="test_value")
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item
+ assert fetched_item.key == item.key
+ assert fetched_item.value == item.value
+
+
+def test_get_nonexistent_item(base: Base[TestItem]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_delete_item(base: Base[TestItem]) -> None:
+ item = TestItem(key="test_key", value="test_value")
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_put_items(base: Base[TestItem]) -> None:
+ items = [TestItem(key=f"test_key_{i}", value=f"test_value_{i}") for i in range(3)]
+ response = base.put(*items)
+ assert isinstance(response, Sequence)
+ assert len(response) == 3
+
+
+def test_fetch_items(base: Base[TestItem]) -> None:
+ items = [TestItem(key=f"test_key_{i}", value=f"test_value_{i}") for i in range(3)]
+ base.put(*items)
+ response = base.fetch()
+ assert isinstance(response, FetchResponse)
+ assert len(response.items) == 3
+
+
+def test_update_item(base: Base[TestItem]) -> None:
+ item = TestItem(key="test_key", value="test_value")
+ base.insert(item)
+ base.update({"value": "updated_value"}, key="test_key")
+ updated_item = base.get("test_key")
+ assert updated_item
+ assert updated_item.value == "updated_value"
From 43ec11434e575584cf59ddd40c61f381e2e5e9bd Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 22:37:57 -0700
Subject: [PATCH 04/29] Add delete everything example
---
examples/basic_usage.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
index 0865947..d1e6d67 100644
--- a/examples/basic_usage.py
+++ b/examples/basic_usage.py
@@ -43,3 +43,7 @@ def print_result(operation: str, result: Any) -> None:
# Delete an item
delete_result = db.delete("jane-doe-py")
+
+# Delete everything
+for item in db.fetch().items:
+ db.delete(str(item["key"]))
From 8fe0e0fc12357bb8f1ff4f6d6fd77b4bff82d326 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 22:40:21 -0700
Subject: [PATCH 05/29] Rename Clients to ApiClients
---
src/contiguity_base/_client.py | 8 ++++----
src/contiguity_base/base.py | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/contiguity_base/_client.py b/src/contiguity_base/_client.py
index fc31f71..d3ec3a9 100644
--- a/src/contiguity_base/_client.py
+++ b/src/contiguity_base/_client.py
@@ -6,9 +6,9 @@
from contiguity_base._auth import get_contiguity_token
-class Client(HttpxClient):
+class ApiClient(HttpxClient):
def __init__(
- self: Client,
+ self: ApiClient,
*,
base_url: str = "https://api.contiguity.co",
api_key: str | None = None,
@@ -26,9 +26,9 @@ def __init__(
)
-class AsyncClient(HttpxAsyncClient):
+class AsyncApiClient(HttpxAsyncClient):
def __init__(
- self: AsyncClient,
+ self: AsyncApiClient,
*,
base_url: str = "https://api.contiguity.co",
api_key: str | None = None,
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 7b8a8f0..52c8a66 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -24,7 +24,7 @@
from typing_extensions import deprecated
from contiguity_base._auth import get_base_token, get_project_id
-from contiguity_base._client import Client
+from contiguity_base._client import ApiClient
if TYPE_CHECKING:
from httpx import Response as HttpxResponse
@@ -182,7 +182,7 @@ def __init__( # noqa: PLR0913
self.json_encoder = json_encoder
self.json_decoder = json_decoder
self.util = _Updates()
- self._client = Client(
+ self._client = ApiClient(
base_url=f"https://{self.host}/{api_version}/{self.project_id}/{self.name}",
api_key=self.base_token,
timeout=300,
From 0b17a84ebd74d9a51d79da74c9e436cfdb1eb351 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 22:44:29 -0700
Subject: [PATCH 06/29] Update implementation for new API and fix typing issues
---
src/contiguity_base/_client.py | 4 +
src/contiguity_base/base.py | 186 +++++++++++++--------------------
2 files changed, 77 insertions(+), 113 deletions(-)
diff --git a/src/contiguity_base/_client.py b/src/contiguity_base/_client.py
index d3ec3a9..1f67c92 100644
--- a/src/contiguity_base/_client.py
+++ b/src/contiguity_base/_client.py
@@ -6,6 +6,10 @@
from contiguity_base._auth import get_contiguity_token
+class ApiError(Exception):
+ pass
+
+
class ApiClient(HttpxClient):
def __init__(
self: ApiClient,
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 52c8a66..85e6a42 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -1,7 +1,4 @@
-# ruff: noqa: TD003, FIX002, ERA001, T201
-# Remove above ignores when issues are fixed.
-# TODO @lemonyte: todo list.
-# - [ ] custom json encoder support
+# TODO @lemonyte: todo list. # noqa: TD003, FIX002
# - [ ] new docstrings
# - [ ] proper tests
# - [ ] add async
@@ -19,12 +16,12 @@
from urllib.parse import quote
from warnings import warn
-from pydantic import BaseModel
+from pydantic import BaseModel, TypeAdapter
from pydantic import JsonValue as DataType
from typing_extensions import deprecated
from contiguity_base._auth import get_base_token, get_project_id
-from contiguity_base._client import ApiClient
+from contiguity_base._client import ApiClient, ApiError
if TYPE_CHECKING:
from httpx import Response as HttpxResponse
@@ -33,12 +30,9 @@
TimestampType = Union[int, datetime]
QueryType = Union[DataType, list[DataType]]
-ItemType = Union[Mapping[str, DataType], BaseModel]
+ItemType = Union[Mapping, BaseModel]
ItemT = TypeVar("ItemT", bound=ItemType)
-DefaultItemT = TypeVar("DefaultItemT", bound=ItemType)
-
-ModelT = TypeVar("ModelT", bound=BaseModel)
-DataT = TypeVar("DataT", bound=DataType)
+DefaultItemT = TypeVar("DefaultItemT")
class _Unset:
@@ -52,11 +46,11 @@ class BaseItem(BaseModel):
key: str
-class ItemConflictError(Exception):
+class ItemConflictError(ApiError):
pass
-class ItemNotFoundError(Exception):
+class ItemNotFoundError(ApiError):
def __init__(self, key: str, *args: object) -> None:
super().__init__(f"key '{key}' not found", *args)
@@ -67,17 +61,20 @@ class FetchResponse(BaseModel, Generic[ItemT]):
items: list[ItemT] = []
-class _UpdateOperation:
- def __init__(self: _UpdateOperation) -> None:
- self.value = None
+class _UpdatePayload(BaseModel):
+ set: dict[str, DataType] = {}
+ increment: dict[str, int] = {}
+ append: dict[str, Sequence[DataType]] = {}
+ prepend: dict[str, Sequence[DataType]] = {}
+ delete: list[str] = []
- def as_dict(self: _UpdateOperation) -> dict[str, DataType]:
- return {"__op": self.__class__.__name__.lower().replace("_", ""), "value": self.value}
+
+class _UpdateOperation:
+ pass
class _Trim(_UpdateOperation):
- def as_dict(self: _UpdateOperation) -> dict[str, DataType]:
- return {"__op": "trim"}
+ pass
class _Increment(_UpdateOperation):
@@ -87,19 +84,14 @@ def __init__(self: _Increment, value: int = 1, /) -> None:
class _Append(_UpdateOperation):
def __init__(self: _Append, value: DataType, /) -> None:
- self.value = value
- # TODO @lemonyte: The API does not support multi-value append and prepend yet.
- # Uncomment this here and in _Prepend when it does.
- # if not isinstance(value, Sequence):
- # # Extra type hint because type checkers can be stupid sometimes.
- # self.value: DataType = [value]
+ if isinstance(value, (list, tuple)):
+ self.value = value
+ else:
+ self.value = [value]
-class _Prepend(_UpdateOperation):
- def __init__(self: _Prepend, value: DataType, /) -> None:
- self.value = value
- # if not isinstance(value, Sequence):
- # self.value: DataType = [value]
+class _Prepend(_Append):
+ pass
class _Updates:
@@ -130,12 +122,11 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] = Mapping,
base_token: str | None = None,
project_id: str | None = None,
host: str | None = None,
api_version: str = "v1",
- json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
) -> None: ...
@@ -146,12 +137,11 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] = Mapping,
project_key: str | None = None,
project_id: str | None = None,
host: str | None = None,
api_version: str = "v1",
- json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
) -> None: ...
@@ -160,14 +150,13 @@ def __init__( # noqa: PLR0913
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] = Mapping,
base_token: str | None = None,
project_key: str | None = None, # Deprecated.
project_id: str | None = None,
host: str | None = None,
api_version: str = "v1",
- json_encoder: type[json.JSONEncoder] = json.JSONEncoder,
- json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a Pydantic model.
) -> None:
if not name:
msg = f"invalid name '{name}'"
@@ -179,7 +168,6 @@ def __init__( # noqa: PLR0913
self.project_id = project_id or get_project_id()
self.host = host or os.getenv("CONTIGUITY_BASE_HOST") or "api.base.contiguity.co"
self.api_version = api_version
- self.json_encoder = json_encoder
self.json_decoder = json_decoder
self.util = _Updates()
self._client = ApiClient(
@@ -188,22 +176,18 @@ def __init__( # noqa: PLR0913
timeout=300,
)
- def _data_as_item_type(self: Self, data: Mapping[str, DataType]) -> ItemT:
- print("data:", data)
+ def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[ItemT]:
+ response.raise_for_status()
if issubclass(self.item_type, BaseModel):
- return self.item_type.model_validate(data)
- # TODO @lemonyte: support dicts as well as BaseModel subclasses.
- # if isinstance(data, self.item_type.__args__[0]):
- # return data
+ return TypeAdapter(list[self.item_type]).validate_json(response.content)
+ # TODO @lemonyte: support TypedDict and parameterized generics. # noqa: TD003, FIX002
+ if isinstance((data := response.json(cls=self.json_decoder)), list) and all(
+ isinstance(item, self.item_type) for item in data
+ ):
+ return data
msg = f"failed to convert data to item type {self.item_type}"
raise ValueError(msg)
- def _response_as_item_type(self: Self, response: HttpxResponse) -> ItemT | Sequence[ItemT]:
- data = response.raise_for_status().json(cls=self.json_decoder)
- if isinstance(data, Sequence):
- return [self._data_as_item_type(item) for item in data]
- return self._data_as_item_type(data)
-
def _insert_expires_attr(
self: Self,
item: ItemT | Mapping[str, DataType],
@@ -218,13 +202,10 @@ def _insert_expires_attr(
if not expire_in and not expire_at:
return item_dict
-
if expire_in:
expire_at = datetime.now(tz=timezone.utc) + timedelta(seconds=expire_in)
-
if isinstance(expire_at, datetime):
expire_at = int(expire_at.replace(microsecond=0).timestamp())
-
if not isinstance(expire_at, int):
msg = "expire_at should be a datetime or int"
raise TypeError(msg)
@@ -235,6 +216,9 @@ def _insert_expires_attr(
@overload
def get(self: Self, key: str, /) -> ItemT | None: ...
+ @overload
+ def get(self: Self, key: str, default: ItemT, /) -> ItemT: ...
+
@overload
def get(self: Self, key: str, default: DefaultItemT, /) -> ItemT | DefaultItemT: ...
@@ -245,7 +229,6 @@ def get(self: Self, key: str, default: DefaultItemT | _Unset = _UNSET, /) -> Ite
key = quote(key, safe="")
response = self._client.get(f"/items/{key}")
-
if response.status_code == HTTPStatus.NOT_FOUND:
if not isinstance(default, _Unset):
return default
@@ -255,17 +238,11 @@ def get(self: Self, key: str, default: DefaultItemT | _Unset = _UNSET, /) -> Ite
)
warn(DeprecationWarning(msg), stacklevel=2)
return None
- # raise ItemNotFoundError(key)
-
- # TODO @lemonyte: due to the API returning both a single item and a list of items,
- # we need to check the response type.
- # Remove when the API is fixed to always return a list of items.
- returned_item = self._response_as_item_type(response)
- if isinstance(returned_item, Sequence):
- # this shouldn't happen, it's just here until the API is fixed
- msg = "expected a single item, got a list of items"
- raise TypeError(msg)
- return returned_item
+
+ if not (returned_item := self._response_as_item_types(response)):
+ msg = "expected a single item, got an empty response"
+ raise ApiError(msg)
+ return returned_item[0]
def delete(self: Self, key: str, /, *, ignore_missing: bool = False) -> None:
"""Delete an item from the Base."""
@@ -294,22 +271,17 @@ def insert(
msg = f"item with key '{item_dict.get('key')}' already exists"
raise ItemConflictError(msg)
- # TODO @lemonyte: due to the API returning both a single item and a list of items,
- # we need to check the response type.
- # Remove when the API is fixed to always return a list of items.
- returned_item = self._response_as_item_type(response)
- if isinstance(returned_item, Sequence):
- # this shouldn't happen, it's just here until the API is fixed
- msg = "expected a single item, got a list of items"
- raise TypeError(msg)
- return returned_item
+ if not (returned_item := self._response_as_item_types(response)):
+ msg = "expected a single item, got an empty response"
+ raise ApiError(msg)
+ return returned_item[0]
def put(
self: Self,
*items: ItemT,
expire_in: int | None = None,
expire_at: TimestampType | None = None,
- ) -> ItemT | Sequence[ItemT]:
+ ) -> Sequence[ItemT]:
"""store (put) an item in the database. Overrides an item if key already exists.
`key` could be provided as function argument or a field in the data dict.
If `key` is not provided, the server will generate a random 12 chars key.
@@ -320,7 +292,7 @@ def put(
item_dicts = [self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at) for item in items]
response = self._client.put("/items", json={"items": item_dicts})
- return self._response_as_item_type(response)
+ return self._response_as_item_types(response)
@deprecated("This method will be removed in the future. You can pass multiple items to `put`.")
def put_many(
@@ -351,15 +323,10 @@ def fetch(
}
if query:
- payload["query"] = query if isinstance(query, list) else [query]
+ payload["query"] = query if isinstance(query, (list, tuple)) else [query]
response = self._client.post("/query", json=payload)
- response_json = response.raise_for_status().json(cls=self.json_decoder)
- return FetchResponse(
- count=response_json.get("count", 0),
- last_key=response_json.get("last", None),
- items=[self._data_as_item_type(item) for item in response_json.get("items", [])],
- )
+ return FetchResponse.model_validate_json(response.content)
def update(
self: Self,
@@ -378,39 +345,32 @@ def update(
msg = f"invalid key '{key}'"
raise ValueError(msg)
- payload = {
- "updates": {
- field: (value.as_dict() if isinstance(value, _UpdateOperation) else {"__op": "set", "value": value})
- for field, value in updates.items()
- },
- }
-
- # payload = self._insert_expires_attr(
- # payload,
- # expire_in=expire_in,
- # expire_at=expire_at,
- # )
-
- # TODO @lemonyte: Remove when the API adds support for __expires.
- if expire_in is not None:
- payload["expireIn"] = expire_in # type: ignore[assignment]
- if expire_at is not None:
- if isinstance(expire_at, datetime):
- payload["expireAt"] = expire_at.isoformat() # type: ignore[assignment]
+ payload = _UpdatePayload()
+ for attr, value in updates.items():
+ if isinstance(value, _UpdateOperation):
+ if isinstance(value, _Trim):
+ payload.delete.append(attr)
+ elif isinstance(value, _Increment):
+ payload.increment[attr] = value.value
+ elif isinstance(value, _Append):
+ payload.append[attr] = value.value
+ elif isinstance(value, _Prepend):
+ payload.prepend[attr] = value.value
else:
- payload["expireAt"] = expire_at # type: ignore[assignment]
+ payload.set[attr] = value
+
+ payload.set = self._insert_expires_attr(
+ payload.set,
+ expire_in=expire_in,
+ expire_at=expire_at,
+ )
key = quote(key, safe="")
response = self._client.patch(f"/items/{key}", json=payload)
if response.status_code == HTTPStatus.NOT_FOUND:
raise ItemNotFoundError(key)
- # TODO @lemonyte: due to the API returning both a single item and a list of items,
- # we need to check the response type.
- # Remove when the API is fixed to always return a list of items.
- returned_item = self._response_as_item_type(response)
- if isinstance(returned_item, Sequence):
- # this shouldn't happen, it's just here until the API is fixed
- msg = "expected a single item, got a list of items"
- raise TypeError(msg)
- return returned_item
+ if not (returned_item := self._response_as_item_types(response)):
+ msg = "expected a single item, got an empty response"
+ raise ApiError(msg)
+ return returned_item[0]
From 802c2d384fc830436240f64e560b2bd515bdece0 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 23:13:13 -0700
Subject: [PATCH 07/29] Move update payload creation to constructor
---
src/contiguity_base/base.py | 47 +++++++++++++++++++++++++------------
1 file changed, 32 insertions(+), 15 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 85e6a42..a371b1c 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -68,6 +68,33 @@ class _UpdatePayload(BaseModel):
prepend: dict[str, Sequence[DataType]] = {}
delete: list[str] = []
+ @classmethod
+ def from_updates_mapping(cls: type[Self], updates: Mapping[str, DataType | _UpdateOperation], /) -> Self:
+ set = {}
+ increment = {}
+ append = {}
+ prepend = {}
+ delete = []
+ for attr, value in updates.items():
+ if isinstance(value, _UpdateOperation):
+ if isinstance(value, _Trim):
+ delete.append(attr)
+ elif isinstance(value, _Increment):
+ increment[attr] = value.value
+ elif isinstance(value, _Append):
+ append[attr] = value.value
+ elif isinstance(value, _Prepend):
+ prepend[attr] = value.value
+ else:
+ set[attr] = value
+ return cls(
+ set=set,
+ increment=increment,
+ append=append,
+ prepend=prepend,
+ delete=delete,
+ )
+
class _UpdateOperation:
pass
@@ -344,21 +371,11 @@ def update(
if not key:
msg = f"invalid key '{key}'"
raise ValueError(msg)
+ if not updates:
+ msg = "no updates provided"
+ raise ValueError(msg)
- payload = _UpdatePayload()
- for attr, value in updates.items():
- if isinstance(value, _UpdateOperation):
- if isinstance(value, _Trim):
- payload.delete.append(attr)
- elif isinstance(value, _Increment):
- payload.increment[attr] = value.value
- elif isinstance(value, _Append):
- payload.append[attr] = value.value
- elif isinstance(value, _Prepend):
- payload.prepend[attr] = value.value
- else:
- payload.set[attr] = value
-
+ payload = _UpdatePayload.from_updates_mapping(updates)
payload.set = self._insert_expires_attr(
payload.set,
expire_in=expire_in,
@@ -366,7 +383,7 @@ def update(
)
key = quote(key, safe="")
- response = self._client.patch(f"/items/{key}", json=payload)
+ response = self._client.patch(f"/items/{key}", json=payload.model_dump())
if response.status_code == HTTPStatus.NOT_FOUND:
raise ItemNotFoundError(key)
From ac85f5b72c62018c007fa45a7d2084168bb69005 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 23:15:17 -0700
Subject: [PATCH 08/29] Better exception handling and raising
---
src/contiguity_base/base.py | 21 +++++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index a371b1c..203b209 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -16,6 +16,7 @@
from urllib.parse import quote
from warnings import warn
+from httpx import HTTPStatusError
from pydantic import BaseModel, TypeAdapter
from pydantic import JsonValue as DataType
from typing_extensions import deprecated
@@ -204,7 +205,10 @@ def __init__( # noqa: PLR0913
)
def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[ItemT]:
- response.raise_for_status()
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
if issubclass(self.item_type, BaseModel):
return TypeAdapter(list[self.item_type]).validate_json(response.content)
# TODO @lemonyte: support TypedDict and parameterized generics. # noqa: TD003, FIX002
@@ -212,7 +216,7 @@ def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[Ite
isinstance(item, self.item_type) for item in data
):
return data
- msg = f"failed to convert data to item type {self.item_type}"
+ msg = f"failed to convert data to item type {self.item_type}\n\t{data}"
raise ValueError(msg)
def _insert_expires_attr(
@@ -271,7 +275,7 @@ def get(self: Self, key: str, default: DefaultItemT | _Unset = _UNSET, /) -> Ite
raise ApiError(msg)
return returned_item[0]
- def delete(self: Self, key: str, /, *, ignore_missing: bool = False) -> None:
+ def delete(self: Self, key: str, /) -> None:
"""Delete an item from the Base."""
if not key:
msg = f"invalid key '{key}'"
@@ -279,9 +283,10 @@ def delete(self: Self, key: str, /, *, ignore_missing: bool = False) -> None:
key = quote(key, safe="")
response = self._client.delete(f"/items/{key}")
- if response.status_code == HTTPStatus.NOT_FOUND and not ignore_missing:
- raise ItemNotFoundError(key)
- response.raise_for_status()
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
def insert(
self: Self,
@@ -353,6 +358,10 @@ def fetch(
payload["query"] = query if isinstance(query, (list, tuple)) else [query]
response = self._client.post("/query", json=payload)
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
return FetchResponse.model_validate_json(response.content)
def update(
From bcf0181d26af26acebb6078d67523f543cb7a8a2 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 23:15:49 -0700
Subject: [PATCH 09/29] Fix a couple typing mistakes
---
src/contiguity_base/base.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 203b209..d429b50 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -253,7 +253,7 @@ def get(self: Self, key: str, default: ItemT, /) -> ItemT: ...
@overload
def get(self: Self, key: str, default: DefaultItemT, /) -> ItemT | DefaultItemT: ...
- def get(self: Self, key: str, default: DefaultItemT | _Unset = _UNSET, /) -> ItemT | DefaultItemT | None:
+ def get(self: Self, key: str, default: ItemT | DefaultItemT | _Unset = _UNSET, /) -> ItemT | DefaultItemT | None:
if not key:
msg = f"invalid key '{key}'"
raise ValueError(msg)
@@ -334,7 +334,7 @@ def put_many(
*,
expire_in: int | None = None,
expire_at: TimestampType | None = None,
- ) -> ItemT | Sequence[ItemT]:
+ ) -> Sequence[ItemT]:
return self.put(*items, expire_in=expire_in, expire_at=expire_at)
def fetch(
From e2155d583cdcef0d57bce062ae63f26cd6981fe3 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Mon, 30 Sep 2024 23:18:10 -0700
Subject: [PATCH 10/29] Dont do anything when putting no items
---
src/contiguity_base/base.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index d429b50..8a42346 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -318,6 +318,8 @@ def put(
`key` could be provided as function argument or a field in the data dict.
If `key` is not provided, the server will generate a random 12 chars key.
"""
+ if not items:
+ return []
if len(items) > self.PUT_LIMIT:
msg = f"cannot put more than {self.PUT_LIMIT} items at a time"
raise ValueError(msg)
From aaea117cf5b3d2c56894b306ee867cb9789c161c Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 00:43:18 -0700
Subject: [PATCH 11/29] Fix fetch implementation
---
src/contiguity_base/base.py | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 8a42346..48222ea 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -1,6 +1,8 @@
# TODO @lemonyte: todo list. # noqa: TD003, FIX002
# - [ ] new docstrings
# - [ ] proper tests
+# - [ ] support models for queries
+# - [ ] examples
# - [ ] add async
# - [ ] add drive support
# - [ ] merge into main sdk
@@ -29,7 +31,7 @@
from typing_extensions import Self
TimestampType = Union[int, datetime]
-QueryType = Union[DataType, list[DataType]]
+QueryType = Mapping[str, DataType]
ItemType = Union[Mapping, BaseModel]
ItemT = TypeVar("ItemT", bound=ItemType)
@@ -341,9 +343,7 @@ def put_many(
def fetch(
self: Self,
- query: QueryType | None = None,
- /,
- *,
+ *queries: QueryType,
limit: int = 1000,
last: str | None = None,
) -> FetchResponse[ItemT]:
@@ -353,18 +353,21 @@ def fetch(
payload = {
"limit": limit,
- "last": last,
+ "last_key": last,
}
- if query:
- payload["query"] = query if isinstance(query, (list, tuple)) else [query]
+ if queries:
+ payload["query"] = queries
response = self._client.post("/query", json=payload)
try:
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
- return FetchResponse.model_validate_json(response.content)
+ fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
+ # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
+ fetch_response.items = TypeAdapter(list[self.item_type]).validate_python(fetch_response.items)
+ return fetch_response
def update(
self: Self,
From ec20312c5a41ee9bd3745e9c19881613d26463ca Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 00:47:58 -0700
Subject: [PATCH 12/29] Move fetch under update
---
src/contiguity_base/base.py | 56 ++++++++++++++++++-------------------
1 file changed, 28 insertions(+), 28 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 48222ea..37bb3a9 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -341,34 +341,6 @@ def put_many(
) -> Sequence[ItemT]:
return self.put(*items, expire_in=expire_in, expire_at=expire_at)
- def fetch(
- self: Self,
- *queries: QueryType,
- limit: int = 1000,
- last: str | None = None,
- ) -> FetchResponse[ItemT]:
- """fetch items from the database.
- `query` is an optional filter or list of filters. Without filter, it will return the whole db.
- """
-
- payload = {
- "limit": limit,
- "last_key": last,
- }
-
- if queries:
- payload["query"] = queries
-
- response = self._client.post("/query", json=payload)
- try:
- response.raise_for_status()
- except HTTPStatusError as exc:
- raise ApiError(exc.response.text) from exc
- fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
- # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
- fetch_response.items = TypeAdapter(list[self.item_type]).validate_python(fetch_response.items)
- return fetch_response
-
def update(
self: Self,
updates: Mapping[str, DataType | _UpdateOperation],
@@ -405,3 +377,31 @@ def update(
msg = "expected a single item, got an empty response"
raise ApiError(msg)
return returned_item[0]
+
+ def fetch(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> FetchResponse[ItemT]:
+ """fetch items from the database.
+ `query` is an optional filter or list of filters. Without filter, it will return the whole db.
+ """
+
+ payload = {
+ "limit": limit,
+ "last_key": last,
+ }
+
+ if queries:
+ payload["query"] = queries
+
+ response = self._client.post("/query", json=payload)
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
+ # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
+ fetch_response.items = TypeAdapter(list[self.item_type]).validate_python(fetch_response.items)
+ return fetch_response
From 5d2e20ceb605f98c34301033830097433c036155 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 00:50:40 -0700
Subject: [PATCH 13/29] Rename fetch to query
---
src/contiguity_base/base.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 37bb3a9..95b124b 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -330,7 +330,7 @@ def put(
response = self._client.put("/items", json={"items": item_dicts})
return self._response_as_item_types(response)
- @deprecated("This method will be removed in the future. You can pass multiple items to `put`.")
+ @deprecated("This method will be removed in a future release. You can pass multiple items to `put`.")
def put_many(
self: Self,
items: Sequence[ItemT],
@@ -378,7 +378,7 @@ def update(
raise ApiError(msg)
return returned_item[0]
- def fetch(
+ def query(
self: Self,
*queries: QueryType,
limit: int = 1000,
@@ -405,3 +405,12 @@ def fetch(
# HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
fetch_response.items = TypeAdapter(list[self.item_type]).validate_python(fetch_response.items)
return fetch_response
+
+ @deprecated("This method has been renamed to `query` and will be removed in a future release.")
+ def fetch(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> FetchResponse[ItemT]:
+ return self.query(*queries, limit=limit, last=last)
From 74047e4d85fd0bbb9204992d25d18ce376ec14e8 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 01:27:12 -0700
Subject: [PATCH 14/29] Add more tests
---
tests/__init__.py | 6 ++
tests/test_base.py | 83 -------------------------
tests/test_base_dict.py | 102 +++++++++++++++++++++++++++++++
tests/test_base_model.py | 111 +++++++++++++++++++++++++++++++++
tests/test_base_typeddict.py | 115 +++++++++++++++++++++++++++++++++++
5 files changed, 334 insertions(+), 83 deletions(-)
delete mode 100644 tests/test_base.py
create mode 100644 tests/test_base_dict.py
create mode 100644 tests/test_base_model.py
create mode 100644 tests/test_base_typeddict.py
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..97f1811 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,6 @@
+import random
+import string
+
+
+def random_string(length: int = 10) -> str:
+ return "".join(random.choices(string.ascii_lowercase, k=length)) # noqa: S311
diff --git a/tests/test_base.py b/tests/test_base.py
deleted file mode 100644
index ffe8c33..0000000
--- a/tests/test_base.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# ruff: noqa: S101, PLR2004
-from collections.abc import Generator, Sequence
-from typing import Any
-
-import pytest
-from pydantic import BaseModel
-
-from contiguity_base.base import Base, FetchResponse, ItemConflictError
-
-
-class TestItem(BaseModel):
- key: str
- value: str
-
-
-@pytest.fixture
-def base() -> Generator[Base[TestItem], Any, None]:
- base = Base("test_base", item_type=TestItem)
- for item in base.fetch().items:
- base.delete(item.key)
- yield base
- for item in base.fetch().items:
- base.delete(item.key)
-
-
-def test_insert_item(base: Base[TestItem]) -> None:
- item = TestItem(key="test_key", value="test_value")
- inserted_item = base.insert(item)
- assert inserted_item.key == item.key
- assert inserted_item.value == item.value
-
-
-def test_insert_existing_item(base: Base[TestItem]) -> None:
- item = TestItem(key="test_key", value="test_value")
- base.insert(item)
- with pytest.raises(ItemConflictError):
- base.insert(item)
-
-
-def test_get_item(base: Base[TestItem]) -> None:
- item = TestItem(key="test_key", value="test_value")
- base.insert(item)
- fetched_item = base.get("test_key")
- assert fetched_item
- assert fetched_item.key == item.key
- assert fetched_item.value == item.value
-
-
-def test_get_nonexistent_item(base: Base[TestItem]) -> None:
- with pytest.warns(DeprecationWarning):
- assert base.get("nonexistent_key") is None
-
-
-def test_delete_item(base: Base[TestItem]) -> None:
- item = TestItem(key="test_key", value="test_value")
- base.insert(item)
- base.delete("test_key")
- with pytest.warns(DeprecationWarning):
- assert base.get("test_key") is None
-
-
-def test_put_items(base: Base[TestItem]) -> None:
- items = [TestItem(key=f"test_key_{i}", value=f"test_value_{i}") for i in range(3)]
- response = base.put(*items)
- assert isinstance(response, Sequence)
- assert len(response) == 3
-
-
-def test_fetch_items(base: Base[TestItem]) -> None:
- items = [TestItem(key=f"test_key_{i}", value=f"test_value_{i}") for i in range(3)]
- base.put(*items)
- response = base.fetch()
- assert isinstance(response, FetchResponse)
- assert len(response.items) == 3
-
-
-def test_update_item(base: Base[TestItem]) -> None:
- item = TestItem(key="test_key", value="test_value")
- base.insert(item)
- base.update({"value": "updated_value"}, key="test_key")
- updated_item = base.get("test_key")
- assert updated_item
- assert updated_item.value == "updated_value"
diff --git a/tests/test_base_dict.py b/tests/test_base_dict.py
new file mode 100644
index 0000000..912b5fc
--- /dev/null
+++ b/tests/test_base_dict.py
@@ -0,0 +1,102 @@
+# ruff: noqa: S101, PLR2004
+from collections.abc import Generator, Mapping
+from typing import Any
+
+import pytest
+from pydantic import JsonValue
+
+from contiguity_base.base import Base, FetchResponse, ItemConflictError
+from tests import random_string
+
+DictItemType = Mapping[str, JsonValue]
+
+
+@pytest.fixture
+def base() -> Generator[Base[DictItemType], Any, None]:
+ base = Base("test_base", item_type=DictItemType)
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+ yield base
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+
+
+def test_get(base: Base[DictItemType]) -> None:
+ item = {"key": "test_key", "value": random_string()}
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[DictItemType]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_delete(base: Base[DictItemType]) -> None:
+ item = {"key": "test_key", "value": random_string()}
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[DictItemType]) -> None:
+ item = {"key": "test_key", "value": random_string()}
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[DictItemType]) -> None:
+ item = {"key": "test_key", "value": random_string()}
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[DictItemType]) -> None:
+ items = [{"key": f"test_key_{i}", "field1": f"test_value_{i}"} for i in range(3)]
+ response = base.put(*items)
+ assert response == items
+
+
+def test_update(base: Base[DictItemType]) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.append([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field2": "",
+ "field3": 3,
+ "field4": -2,
+ "field5": ["foo", "bar", "baz"],
+ "field6": [1, 2, 3, 4],
+ "field7": {"foo": "bar"},
+ }
+
+
+def test_fetch(base: Base[DictItemType]) -> None:
+ items = [{"key": f"test_key_{i}", "field1": f"test_value_{i}"} for i in range(3)]
+ base.put(*items)
+ response = base.query()
+ assert response == FetchResponse(count=3, last_key=None, items=items)
diff --git a/tests/test_base_model.py b/tests/test_base_model.py
new file mode 100644
index 0000000..7de79a1
--- /dev/null
+++ b/tests/test_base_model.py
@@ -0,0 +1,111 @@
+# ruff: noqa: S101, PLR2004
+from collections.abc import Generator
+from typing import Any
+
+import pytest
+from pydantic import BaseModel
+
+from contiguity_base.base import Base, FetchResponse, ItemConflictError
+from tests import random_string
+
+
+class TestItemModel(BaseModel):
+ key: str
+ field1: str = ""
+ field2: str = ""
+ field3: int = 0
+ field4: int = 0
+ field5: list[str] = []
+ field6: list[int] = []
+ field7: dict[str, str] = {}
+
+
+@pytest.fixture
+def base() -> Generator[Base[TestItemModel], Any, None]:
+ base = Base("test_base", item_type=TestItemModel)
+ for item in base.query().items:
+ base.delete(item.key)
+ yield base
+ for item in base.query().items:
+ base.delete(item.key)
+
+
+def test_get(base: Base[TestItemModel]) -> None:
+ item = TestItemModel(key="test_key", field1=random_string())
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[TestItemModel]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_delete(base: Base[TestItemModel]) -> None:
+ item = TestItemModel(key="test_key", field1=random_string())
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[TestItemModel]) -> None:
+ item = TestItemModel(key="test_key", field1=random_string())
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[TestItemModel]) -> None:
+ item = TestItemModel(key="test_key", field1=random_string())
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ response = base.put(*items)
+ assert response == items
+
+
+def test_update(base: Base[TestItemModel]) -> None:
+ item = TestItemModel(
+ key="test_key",
+ field1=random_string(),
+ field2=random_string(),
+ field3=1,
+ field4=0,
+ field5=["foo", "bar"],
+ field6=[1, 2],
+ field7={"foo": "bar"},
+ )
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.append([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemModel(
+ key="test_key",
+ field1="updated_value",
+ field2="",
+ field3=3,
+ field4=-2,
+ field5=["foo", "bar", "baz"],
+ field6=[1, 2, 3, 4],
+ field7={"foo": "bar"},
+ )
+
+
+def test_fetch(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ base.put(*items)
+ response = base.query()
+ assert response == FetchResponse(count=3, last_key=None, items=items)
diff --git a/tests/test_base_typeddict.py b/tests/test_base_typeddict.py
new file mode 100644
index 0000000..8ab9dad
--- /dev/null
+++ b/tests/test_base_typeddict.py
@@ -0,0 +1,115 @@
+# ruff: noqa: S101, PLR2004
+from collections.abc import Generator
+from typing import Any, TypedDict
+
+import pytest
+
+from contiguity_base.base import Base, FetchResponse, ItemConflictError
+from tests import random_string
+
+
+class TestItemDict(TypedDict):
+ key: str
+ field1: str
+
+
+class TestItemDict2(TypedDict):
+ key: str
+ field1: str
+ field2: str
+ field3: int
+ field4: int
+ field5: list[str]
+ field6: list[int]
+ field7: dict[str, str]
+
+
+@pytest.fixture
+def base() -> Generator[Base[TestItemDict], Any, None]:
+ base = Base("test_base", item_type=TestItemDict)
+ for item in base.query().items:
+ base.delete(item["key"])
+ yield base
+ for item in base.query().items:
+ base.delete(item["key"])
+
+
+def test_get(base: Base[TestItemDict]) -> None:
+ item = TestItemDict(key="test_key", field1=random_string())
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[TestItemDict]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_delete(base: Base[TestItemDict]) -> None:
+ item = TestItemDict(key="test_key", field1=random_string())
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[TestItemDict]) -> None:
+ item = TestItemDict(key="test_key", field1=random_string())
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[TestItemDict]) -> None:
+ item = TestItemDict(key="test_key", field1=random_string())
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[TestItemDict]) -> None:
+ items = [TestItemDict(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ response = base.put(*items)
+ assert response == items
+
+
+def test_update(base: Base[TestItemDict]) -> None:
+ item = TestItemDict2(
+ key="test_key",
+ field1=random_string(),
+ field2=random_string(),
+ field3=1,
+ field4=0,
+ field5=["foo", "bar"],
+ field6=[1, 2],
+ field7={"foo": "bar"},
+ )
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.append([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemDict2(
+ key="test_key",
+ field1="updated_value",
+ field2="",
+ field3=3,
+ field4=-2,
+ field5=["foo", "bar", "baz"],
+ field6=[1, 2, 3, 4],
+ field7={"foo": "bar"},
+ )
+
+
+def test_fetch(base: Base[TestItemDict]) -> None:
+ items = [TestItemDict(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ base.put(*items)
+ response = base.query()
+ assert response == FetchResponse(count=3, last_key=None, items=items)
From 3fe421d3d8cee2d843f2edb655097977415a708a Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 01:27:29 -0700
Subject: [PATCH 15/29] Add api docs
---
api.md | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 189 insertions(+)
create mode 100644 api.md
diff --git a/api.md b/api.md
new file mode 100644
index 0000000..9cd621b
--- /dev/null
+++ b/api.md
@@ -0,0 +1,189 @@
+# Base API
+
+## GET /items/{key}
+
+SDK method: `get(key: str) -> Item`
+
+Response `200`:
+Returns a single item.
+
+```json
+{
+ "key": "foo",
+ "field1": "bar"
+ // ...other item fields
+}
+```
+
+Response `404`:
+Key does not exist.
+
+```text
+No content
+```
+
+## DELETE /items/{key}
+
+SDK method: `delete(key: str) -> None`
+
+Response `204`:
+Returns nothing.
+
+```text
+No content
+```
+
+## POST /items
+
+SDK method: `insert(item: Item) -> Item`
+
+Request body:
+
+```json
+{
+ "item": {
+ "key": "foo",
+ "field1": "bar",
+ // ...other item fields
+ "__expires": 1727765807 // optional Unix timestamp
+ }
+}
+```
+
+Response `201`:
+Returns a list of items.
+
+```json
+[
+ {
+ "key": "foo",
+ "field1": "bar"
+ // ...other item fields
+ }
+ // ...other items
+]
+```
+
+Response `409`:
+Key already exists.
+
+```text
+No content
+```
+
+## PUT /items
+
+SDK method: `put(items: Item[]) -> Item[]`
+
+Request body:
+
+```json
+{
+ "items": [
+ {
+ "key": "foo",
+ "field1": "bar",
+ // ...other item fields
+ "__expires": 1727765807 // optional Unix timestamp
+ }
+ // ...other items
+ ]
+}
+```
+
+Response `201`:
+Returns a list of items.
+
+```json
+[
+ {
+ "key": "foo",
+ "field1": "bar"
+ // ...other item fields
+ }
+ // ...other items
+]
+```
+
+## PATCH /items/{key}
+
+SDK method: `update(updates: Mapping, key: str) -> Item`
+
+Request body:
+
+```json
+{
+ "set": {
+ "field1": "bar",
+ // ...other item fields
+ "__expires": 1727765807 // optional Unix timestamp
+ },
+ "increment": {
+ "field2": 1,
+ "field3": -2
+ },
+ "append": {
+ "field4": ["baz"]
+ },
+ "prepend": {
+ "field5": ["foo", "bar"]
+ },
+ "delete": ["field6"]
+}
+```
+
+Response `200`:
+Returns a single item.
+
+```json
+{
+ "key": "foo",
+ "field1": "bar"
+ // ...other item fields
+}
+```
+
+Response `404`:
+Key does not exist.
+
+```text
+No content
+```
+
+## GET /query
+
+SDK method: `fetch(*query: Query) -> Item[]`
+
+Request body:
+
+```json
+{
+ "limit": 10,
+ "last_key": "foo",
+ "query": [
+ {
+ "field1": "foo",
+ "field4?contains": "bar"
+ // multiple conditions are ANDed
+ },
+ {
+ "field3?gt": 2
+ }
+ // multiple queries are ORed
+ ]
+}
+```
+
+Response `200`:
+Returns a list of items.
+
+```json
+[
+ {
+ "key": "foo",
+ "field1": "bar"
+ // ...other item fields
+ }
+ // ...other items
+]
+```
From 27192e6194f2d31cb30d248a537d8eafbc2e8eab Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 01:27:55 -0700
Subject: [PATCH 16/29] Use query instead of fetch
---
examples/basic_usage.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
index d1e6d67..777671e 100644
--- a/examples/basic_usage.py
+++ b/examples/basic_usage.py
@@ -36,7 +36,7 @@ def print_result(operation: str, result: Any) -> None:
# Fetch items
try:
- fetch_result = db.fetch({"age": {"$gt": 25}}, limit=10)
+ fetch_result = db.query({"age": {"$gt": 25}}, limit=10)
print_result("Fetch", fetch_result)
except Exception as exc:
print(f"Fetch operation failed: {exc}")
@@ -45,5 +45,5 @@ def print_result(operation: str, result: Any) -> None:
delete_result = db.delete("jane-doe-py")
# Delete everything
-for item in db.fetch().items:
+for item in db.query().items:
db.delete(str(item["key"]))
From a1b1603565f65e83fa31e6c4e3e70cc35540d9de Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 20:05:50 -0700
Subject: [PATCH 17/29] Add python-dotenv for tests
---
pyproject.toml | 1 +
uv.lock | 11 +++++++++++
2 files changed, 12 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index feaa287..91b9e60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,6 +49,7 @@ version = {attr = "contiguity_base.__version__"}
dev-dependencies = [
"pre-commit~=3.8.0",
"pytest~=8.3.3",
+ "python-dotenv~=1.0.1",
]
[tool.ruff]
diff --git a/uv.lock b/uv.lock
index bf0d4ee..ea99f0d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -70,6 +70,7 @@ dependencies = [
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
+ { name = "python-dotenv" },
]
[package.metadata]
@@ -83,6 +84,7 @@ requires-dist = [
dev = [
{ name = "pre-commit", specifier = "~=3.8.0" },
{ name = "pytest", specifier = "~=8.3.3" },
+ { name = "python-dotenv", specifier = "~=1.0.1" },
]
[[package]]
@@ -347,6 +349,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
]
+[[package]]
+name = "python-dotenv"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.2"
From 83e949145b2167e8e1e16dce33bd28af2e569fb5 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 20:06:13 -0700
Subject: [PATCH 18/29] Fix update payload
---
src/contiguity_base/base.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 95b124b..414208c 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -84,10 +84,11 @@ def from_updates_mapping(cls: type[Self], updates: Mapping[str, DataType | _Upda
delete.append(attr)
elif isinstance(value, _Increment):
increment[attr] = value.value
- elif isinstance(value, _Append):
- append[attr] = value.value
+ # Prepend must be checked before Append because it's a subclass of Append.
elif isinstance(value, _Prepend):
prepend[attr] = value.value
+ elif isinstance(value, _Append):
+ append[attr] = value.value
else:
set[attr] = value
return cls(
From c0dccc886b10c2e3e7dc21219daef02e2da5fb1f Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 20:08:28 -0700
Subject: [PATCH 19/29] Better type validation
---
src/contiguity_base/base.py | 35 ++++++++++++++---------------------
1 file changed, 14 insertions(+), 21 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 414208c..2e50dec 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -153,7 +153,7 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping,
+ item_type: type[ItemT] = Mapping[str, DataType],
base_token: str | None = None,
project_id: str | None = None,
host: str | None = None,
@@ -168,7 +168,7 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping,
+ item_type: type[ItemT] = Mapping[str, DataType],
project_key: str | None = None,
project_id: str | None = None,
host: str | None = None,
@@ -181,7 +181,7 @@ def __init__( # noqa: PLR0913
name: str,
/,
*,
- item_type: type[ItemT] = Mapping,
+ item_type: type[ItemT] = Mapping[str, DataType],
base_token: str | None = None,
project_key: str | None = None, # Deprecated.
project_id: str | None = None,
@@ -207,20 +207,19 @@ def __init__( # noqa: PLR0913
timeout=300,
)
+ def _response_as_item_type(self: Self, response: HttpxResponse) -> ItemT:
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ return TypeAdapter(self.item_type).validate_json(response.content)
+
def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[ItemT]:
try:
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
- if issubclass(self.item_type, BaseModel):
- return TypeAdapter(list[self.item_type]).validate_json(response.content)
- # TODO @lemonyte: support TypedDict and parameterized generics. # noqa: TD003, FIX002
- if isinstance((data := response.json(cls=self.json_decoder)), list) and all(
- isinstance(item, self.item_type) for item in data
- ):
- return data
- msg = f"failed to convert data to item type {self.item_type}\n\t{data}"
- raise ValueError(msg)
+ return TypeAdapter(list[self.item_type]).validate_json(response.content)
def _insert_expires_attr(
self: Self,
@@ -273,10 +272,7 @@ def get(self: Self, key: str, default: ItemT | DefaultItemT | _Unset = _UNSET, /
warn(DeprecationWarning(msg), stacklevel=2)
return None
- if not (returned_item := self._response_as_item_types(response)):
- msg = "expected a single item, got an empty response"
- raise ApiError(msg)
- return returned_item[0]
+ return self._response_as_item_type(response)
def delete(self: Self, key: str, /) -> None:
"""Delete an item from the Base."""
@@ -370,14 +366,11 @@ def update(
)
key = quote(key, safe="")
- response = self._client.patch(f"/items/{key}", json=payload.model_dump())
+ response = self._client.patch(f"/items/{key}", json={"updates": payload.model_dump()})
if response.status_code == HTTPStatus.NOT_FOUND:
raise ItemNotFoundError(key)
- if not (returned_item := self._response_as_item_types(response)):
- msg = "expected a single item, got an empty response"
- raise ApiError(msg)
- return returned_item[0]
+ return self._response_as_item_type(response)
def query(
self: Self,
From 3b2af63deb5a085fd3d851107967d433b16b4652 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 1 Oct 2024 20:17:34 -0700
Subject: [PATCH 20/29] Fix tests
---
tests/test_base_dict.py | 43 ++++++++++++++--------
tests/test_base_model.py | 58 ++++++++++++++----------------
tests/test_base_typeddict.py | 70 +++++++++++++++++++-----------------
3 files changed, 92 insertions(+), 79 deletions(-)
diff --git a/tests/test_base_dict.py b/tests/test_base_dict.py
index 912b5fc..d518a63 100644
--- a/tests/test_base_dict.py
+++ b/tests/test_base_dict.py
@@ -3,17 +3,33 @@
from typing import Any
import pytest
+from dotenv import load_dotenv
from pydantic import JsonValue
from contiguity_base.base import Base, FetchResponse, ItemConflictError
from tests import random_string
+load_dotenv()
+
DictItemType = Mapping[str, JsonValue]
+def create_test_item(key: str = "test_key", /) -> DictItemType:
+ return {
+ "key": key,
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+
+
@pytest.fixture
def base() -> Generator[Base[DictItemType], Any, None]:
- base = Base("test_base", item_type=DictItemType)
+ base = Base("test_base_dict", item_type=DictItemType)
for item in base.query().items:
base.delete(str(item["key"]))
yield base
@@ -22,7 +38,7 @@ def base() -> Generator[Base[DictItemType], Any, None]:
def test_get(base: Base[DictItemType]) -> None:
- item = {"key": "test_key", "value": random_string()}
+ item = create_test_item()
base.insert(item)
fetched_item = base.get("test_key")
assert fetched_item == item
@@ -34,7 +50,7 @@ def test_get_nonexistent(base: Base[DictItemType]) -> None:
def test_delete(base: Base[DictItemType]) -> None:
- item = {"key": "test_key", "value": random_string()}
+ item = create_test_item()
base.insert(item)
base.delete("test_key")
with pytest.warns(DeprecationWarning):
@@ -42,20 +58,20 @@ def test_delete(base: Base[DictItemType]) -> None:
def test_insert(base: Base[DictItemType]) -> None:
- item = {"key": "test_key", "value": random_string()}
+ item = create_test_item()
inserted_item = base.insert(item)
assert inserted_item == item
def test_insert_existing(base: Base[DictItemType]) -> None:
- item = {"key": "test_key", "value": random_string()}
+ item = create_test_item()
base.insert(item)
with pytest.raises(ItemConflictError):
base.insert(item)
def test_put(base: Base[DictItemType]) -> None:
- items = [{"key": f"test_key_{i}", "field1": f"test_value_{i}"} for i in range(3)]
+ items = [create_test_item(f"test_key_{i}") for i in range(3)]
response = base.put(*items)
assert response == items
@@ -79,23 +95,22 @@ def test_update(base: Base[DictItemType]) -> None:
"field3": base.util.increment(2),
"field4": base.util.increment(-2),
"field5": base.util.append("baz"),
- "field6": base.util.append([3, 4]),
+ "field6": base.util.prepend([3, 4]),
},
key="test_key",
)
assert updated_item == {
"key": "test_key",
"field1": "updated_value",
- "field2": "",
- "field3": 3,
- "field4": -2,
- "field5": ["foo", "bar", "baz"],
- "field6": [1, 2, 3, 4],
- "field7": {"foo": "bar"},
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
}
-def test_fetch(base: Base[DictItemType]) -> None:
+def test_query(base: Base[DictItemType]) -> None:
items = [{"key": f"test_key_{i}", "field1": f"test_value_{i}"} for i in range(3)]
base.put(*items)
response = base.query()
diff --git a/tests/test_base_model.py b/tests/test_base_model.py
index 7de79a1..9829ed4 100644
--- a/tests/test_base_model.py
+++ b/tests/test_base_model.py
@@ -3,26 +3,29 @@
from typing import Any
import pytest
+from dotenv import load_dotenv
from pydantic import BaseModel
from contiguity_base.base import Base, FetchResponse, ItemConflictError
from tests import random_string
+load_dotenv()
+
class TestItemModel(BaseModel):
- key: str
- field1: str = ""
- field2: str = ""
- field3: int = 0
+ key: str = "test_key"
+ field1: str = random_string()
+ field2: str = random_string()
+ field3: int = 1
field4: int = 0
- field5: list[str] = []
- field6: list[int] = []
- field7: dict[str, str] = {}
+ field5: list[str] = ["foo", "bar"]
+ field6: list[int] = [1, 2]
+ field7: dict[str, str] = {"foo": "bar"}
@pytest.fixture
def base() -> Generator[Base[TestItemModel], Any, None]:
- base = Base("test_base", item_type=TestItemModel)
+ base = Base("test_base_model", item_type=TestItemModel)
for item in base.query().items:
base.delete(item.key)
yield base
@@ -31,7 +34,7 @@ def base() -> Generator[Base[TestItemModel], Any, None]:
def test_get(base: Base[TestItemModel]) -> None:
- item = TestItemModel(key="test_key", field1=random_string())
+ item = TestItemModel()
base.insert(item)
fetched_item = base.get("test_key")
assert fetched_item == item
@@ -43,7 +46,7 @@ def test_get_nonexistent(base: Base[TestItemModel]) -> None:
def test_delete(base: Base[TestItemModel]) -> None:
- item = TestItemModel(key="test_key", field1=random_string())
+ item = TestItemModel()
base.insert(item)
base.delete("test_key")
with pytest.warns(DeprecationWarning):
@@ -51,35 +54,26 @@ def test_delete(base: Base[TestItemModel]) -> None:
def test_insert(base: Base[TestItemModel]) -> None:
- item = TestItemModel(key="test_key", field1=random_string())
+ item = TestItemModel()
inserted_item = base.insert(item)
assert inserted_item == item
def test_insert_existing(base: Base[TestItemModel]) -> None:
- item = TestItemModel(key="test_key", field1=random_string())
+ item = TestItemModel()
base.insert(item)
with pytest.raises(ItemConflictError):
base.insert(item)
def test_put(base: Base[TestItemModel]) -> None:
- items = [TestItemModel(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
response = base.put(*items)
assert response == items
def test_update(base: Base[TestItemModel]) -> None:
- item = TestItemModel(
- key="test_key",
- field1=random_string(),
- field2=random_string(),
- field3=1,
- field4=0,
- field5=["foo", "bar"],
- field6=[1, 2],
- field7={"foo": "bar"},
- )
+ item = TestItemModel()
base.insert(item)
updated_item = base.update(
{
@@ -88,24 +82,24 @@ def test_update(base: Base[TestItemModel]) -> None:
"field3": base.util.increment(2),
"field4": base.util.increment(-2),
"field5": base.util.append("baz"),
- "field6": base.util.append([3, 4]),
+ "field6": base.util.prepend([3, 4]),
},
key="test_key",
)
assert updated_item == TestItemModel(
key="test_key",
field1="updated_value",
- field2="",
- field3=3,
- field4=-2,
- field5=["foo", "bar", "baz"],
- field6=[1, 2, 3, 4],
- field7={"foo": "bar"},
+ field2=updated_item.field2,
+ field3=item.field3 + 2,
+ field4=item.field4 - 2,
+ field5=[*item.field5, "baz"],
+ field6=[3, 4, *item.field6],
+ field7=item.field7,
)
-def test_fetch(base: Base[TestItemModel]) -> None:
- items = [TestItemModel(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+def test_query(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
base.put(*items)
response = base.query()
assert response == FetchResponse(count=3, last_key=None, items=items)
diff --git a/tests/test_base_typeddict.py b/tests/test_base_typeddict.py
index 8ab9dad..9026861 100644
--- a/tests/test_base_typeddict.py
+++ b/tests/test_base_typeddict.py
@@ -1,19 +1,18 @@
# ruff: noqa: S101, PLR2004
from collections.abc import Generator
-from typing import Any, TypedDict
+from typing import Any
import pytest
+from dotenv import load_dotenv
+from typing_extensions import TypedDict
from contiguity_base.base import Base, FetchResponse, ItemConflictError
from tests import random_string
+load_dotenv()
-class TestItemDict(TypedDict):
- key: str
- field1: str
-
-class TestItemDict2(TypedDict):
+class TestItemDict(TypedDict):
key: str
field1: str
field2: str
@@ -24,9 +23,22 @@ class TestItemDict2(TypedDict):
field7: dict[str, str]
+def create_test_item(key: str = "test_key", /) -> TestItemDict:
+ return {
+ "key": key,
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+
+
@pytest.fixture
def base() -> Generator[Base[TestItemDict], Any, None]:
- base = Base("test_base", item_type=TestItemDict)
+ base = Base("test_base_typeddict", item_type=TestItemDict)
for item in base.query().items:
base.delete(item["key"])
yield base
@@ -35,7 +47,7 @@ def base() -> Generator[Base[TestItemDict], Any, None]:
def test_get(base: Base[TestItemDict]) -> None:
- item = TestItemDict(key="test_key", field1=random_string())
+ item = create_test_item()
base.insert(item)
fetched_item = base.get("test_key")
assert fetched_item == item
@@ -47,7 +59,7 @@ def test_get_nonexistent(base: Base[TestItemDict]) -> None:
def test_delete(base: Base[TestItemDict]) -> None:
- item = TestItemDict(key="test_key", field1=random_string())
+ item = create_test_item()
base.insert(item)
base.delete("test_key")
with pytest.warns(DeprecationWarning):
@@ -55,61 +67,53 @@ def test_delete(base: Base[TestItemDict]) -> None:
def test_insert(base: Base[TestItemDict]) -> None:
- item = TestItemDict(key="test_key", field1=random_string())
+ item = create_test_item()
inserted_item = base.insert(item)
assert inserted_item == item
def test_insert_existing(base: Base[TestItemDict]) -> None:
- item = TestItemDict(key="test_key", field1=random_string())
+ item = create_test_item()
base.insert(item)
with pytest.raises(ItemConflictError):
base.insert(item)
def test_put(base: Base[TestItemDict]) -> None:
- items = [TestItemDict(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+ items = [create_test_item(f"test_key_{i}") for i in range(3)]
response = base.put(*items)
assert response == items
def test_update(base: Base[TestItemDict]) -> None:
- item = TestItemDict2(
- key="test_key",
- field1=random_string(),
- field2=random_string(),
- field3=1,
- field4=0,
- field5=["foo", "bar"],
- field6=[1, 2],
- field7={"foo": "bar"},
- )
+ item = create_test_item()
base.insert(item)
updated_item = base.update(
{
"field1": "updated_value",
- "field2": base.util.trim(),
+ # Trim will not pass type validation when using TypedDict
+ # because TypedDict does not support default values.
"field3": base.util.increment(2),
"field4": base.util.increment(-2),
"field5": base.util.append("baz"),
- "field6": base.util.append([3, 4]),
+ "field6": base.util.prepend([3, 4]),
},
key="test_key",
)
- assert updated_item == TestItemDict2(
+ assert updated_item == TestItemDict(
key="test_key",
field1="updated_value",
- field2="",
- field3=3,
- field4=-2,
- field5=["foo", "bar", "baz"],
- field6=[1, 2, 3, 4],
- field7={"foo": "bar"},
+ field2=item["field2"],
+ field3=item["field3"] + 2,
+ field4=item["field4"] - 2,
+ field5=[*item["field5"], "baz"],
+ field6=[3, 4, *item["field6"]],
+ field7=item["field7"],
)
-def test_fetch(base: Base[TestItemDict]) -> None:
- items = [TestItemDict(key=f"test_key_{i}", field1=f"test_value_{i}") for i in range(3)]
+def test_query(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(f"test_key_{i}") for i in range(3)]
base.put(*items)
response = base.query()
assert response == FetchResponse(count=3, last_key=None, items=items)
From fb73cfdcba2f9dfb7c9e49c699527483ecf59004 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 8 Oct 2024 23:29:09 -0700
Subject: [PATCH 21/29] Make validation optional
---
src/contiguity_base/base.py | 38 +++++++++++++++++++++++--------------
1 file changed, 24 insertions(+), 14 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 2e50dec..1e4f683 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -1,6 +1,7 @@
# TODO @lemonyte: todo list. # noqa: TD003, FIX002
# - [ ] new docstrings
-# - [ ] proper tests
+# - [ ] more tests
+# - [ ] support dataclasses
# - [ ] support models for queries
# - [ ] examples
# - [ ] add async
@@ -14,7 +15,7 @@
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
-from typing import TYPE_CHECKING, Generic, TypeVar, Union, overload
+from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, overload
from urllib.parse import quote
from warnings import warn
@@ -33,7 +34,7 @@
TimestampType = Union[int, datetime]
QueryType = Mapping[str, DataType]
-ItemType = Union[Mapping, BaseModel]
+ItemType = Union[Mapping[str, Any], BaseModel]
ItemT = TypeVar("ItemT", bound=ItemType)
DefaultItemT = TypeVar("DefaultItemT")
@@ -61,7 +62,7 @@ def __init__(self, key: str, *args: object) -> None:
class FetchResponse(BaseModel, Generic[ItemT]):
count: int = 0
last_key: Union[str, None] = None # noqa: UP007 Pydantic doesn't support `X | Y` syntax in Python 3.9.
- items: list[ItemT] = []
+ items: Sequence[ItemT] = []
class _UpdatePayload(BaseModel):
@@ -153,7 +154,7 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] | None = None,
base_token: str | None = None,
project_id: str | None = None,
host: str | None = None,
@@ -168,7 +169,7 @@ def __init__(
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] | None = None,
project_key: str | None = None,
project_id: str | None = None,
host: str | None = None,
@@ -181,7 +182,7 @@ def __init__( # noqa: PLR0913
name: str,
/,
*,
- item_type: type[ItemT] = Mapping[str, DataType],
+ item_type: type[ItemT] | None = None,
base_token: str | None = None,
project_key: str | None = None, # Deprecated.
project_id: str | None = None,
@@ -212,14 +213,18 @@ def _response_as_item_type(self: Self, response: HttpxResponse) -> ItemT:
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
- return TypeAdapter(self.item_type).validate_json(response.content)
+ if self.item_type:
+ return TypeAdapter(self.item_type).validate_json(response.content)
+ return response.json(cls=self.json_decoder)
def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[ItemT]:
try:
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
- return TypeAdapter(list[self.item_type]).validate_json(response.content)
+ if self.item_type:
+ return TypeAdapter(list[self.item_type]).validate_json(response.content)
+ return response.json(cls=self.json_decoder)
def _insert_expires_attr(
self: Self,
@@ -231,7 +236,10 @@ def _insert_expires_attr(
msg = "cannot use both expire_in and expire_at"
raise ValueError(msg)
- item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
+ if isinstance(item, BaseModel):
+ item_dict = item.model_dump()
+ elif isinstance(item, Mapping):
+ item_dict = dict(item)
if not expire_in and not expire_at:
return item_dict
@@ -395,10 +403,12 @@ def query(
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
- fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
- # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
- fetch_response.items = TypeAdapter(list[self.item_type]).validate_python(fetch_response.items)
- return fetch_response
+ if self.item_type:
+ fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
+ # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
+ fetch_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(fetch_response.items)
+ return fetch_response
+ return response.json(cls=self.json_decoder)
@deprecated("This method has been renamed to `query` and will be removed in a future release.")
def fetch(
From dfb1667dec0e20ed1bf2a792b30414ca456e62c8 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Tue, 8 Oct 2024 23:46:32 -0700
Subject: [PATCH 22/29] Reduce code duplication
---
src/contiguity_base/base.py | 48 ++++++++++++++++++++++++-------------
1 file changed, 32 insertions(+), 16 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 1e4f683..17798e4 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -15,7 +15,7 @@
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
-from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, overload
+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union, overload
from urllib.parse import quote
from warnings import warn
@@ -208,22 +208,38 @@ def __init__( # noqa: PLR0913
timeout=300,
)
- def _response_as_item_type(self: Self, response: HttpxResponse) -> ItemT:
- try:
- response.raise_for_status()
- except HTTPStatusError as exc:
- raise ApiError(exc.response.text) from exc
- if self.item_type:
- return TypeAdapter(self.item_type).validate_json(response.content)
- return response.json(cls=self.json_decoder)
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[False] = False,
+ ) -> ItemT: ...
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[True] = True,
+ ) -> Sequence[ItemT]: ...
- def _response_as_item_types(self: Self, response: HttpxResponse) -> Sequence[ItemT]:
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: bool = False,
+ ) -> ItemT | Sequence[ItemT]:
try:
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
if self.item_type:
- return TypeAdapter(list[self.item_type]).validate_json(response.content)
+ if sequence:
+ return TypeAdapter(Sequence[self.item_type]).validate_json(response.content)
+ return TypeAdapter(self.item_type).validate_json(response.content)
return response.json(cls=self.json_decoder)
def _insert_expires_attr(
@@ -280,7 +296,7 @@ def get(self: Self, key: str, default: ItemT | DefaultItemT | _Unset = _UNSET, /
warn(DeprecationWarning(msg), stacklevel=2)
return None
- return self._response_as_item_type(response)
+ return self._response_as_item_type(response, sequence=False)
def delete(self: Self, key: str, /) -> None:
"""Delete an item from the Base."""
@@ -310,7 +326,7 @@ def insert(
msg = f"item with key '{item_dict.get('key')}' already exists"
raise ItemConflictError(msg)
- if not (returned_item := self._response_as_item_types(response)):
+ if not (returned_item := self._response_as_item_type(response, sequence=True)):
msg = "expected a single item, got an empty response"
raise ApiError(msg)
return returned_item[0]
@@ -333,7 +349,7 @@ def put(
item_dicts = [self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at) for item in items]
response = self._client.put("/items", json={"items": item_dicts})
- return self._response_as_item_types(response)
+ return self._response_as_item_type(response, sequence=True)
@deprecated("This method will be removed in a future release. You can pass multiple items to `put`.")
def put_many(
@@ -378,7 +394,7 @@ def update(
if response.status_code == HTTPStatus.NOT_FOUND:
raise ItemNotFoundError(key)
- return self._response_as_item_type(response)
+ return self._response_as_item_type(response, sequence=False)
def query(
self: Self,
@@ -405,7 +421,7 @@ def query(
raise ApiError(exc.response.text) from exc
if self.item_type:
fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
- # HACK: Pydantic doesn't validate list[ItemT] properly. # noqa: FIX004
+ # HACK: Pydantic model_validate_json doesn't validate Sequence[ItemT] properly. # noqa: FIX004
fetch_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(fetch_response.items)
return fetch_response
return response.json(cls=self.json_decoder)
From b11321ce8c16475a1c5046bdfda756307d711e86 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Wed, 9 Oct 2024 00:00:51 -0700
Subject: [PATCH 23/29] Make pyright report unnecessary ignores
---
pyproject.toml | 1 +
1 file changed, 1 insertion(+)
diff --git a/pyproject.toml b/pyproject.toml
index 91b9e60..5b2afa6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,3 +64,4 @@ ignore = ["A", "D"]
[tool.pyright]
venvPath = "."
venv = ".venv"
+reportUnnecessaryTypeIgnoreComment = true
From 267dac4f44e76ff7a9d69196fd39bbb532064bab Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Wed, 9 Oct 2024 00:02:45 -0700
Subject: [PATCH 24/29] Skip isinstance check on dicts
---
src/contiguity_base/base.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index 17798e4..da39f27 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -252,10 +252,7 @@ def _insert_expires_attr(
msg = "cannot use both expire_in and expire_at"
raise ValueError(msg)
- if isinstance(item, BaseModel):
- item_dict = item.model_dump()
- elif isinstance(item, Mapping):
- item_dict = dict(item)
+ item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
if not expire_in and not expire_at:
return item_dict
From a25c18286594e8b2ddccb2c6d9c06ef528fdfe86 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Wed, 9 Oct 2024 00:40:13 -0700
Subject: [PATCH 25/29] Rename FetchResponse to QueryResponse
---
src/contiguity_base/__init__.py | 4 ++--
src/contiguity_base/base.py | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/contiguity_base/__init__.py b/src/contiguity_base/__init__.py
index 59eea52..a4318eb 100644
--- a/src/contiguity_base/__init__.py
+++ b/src/contiguity_base/__init__.py
@@ -1,10 +1,10 @@
-from .base import Base, BaseItem, FetchResponse, ItemConflictError, ItemNotFoundError
+from .base import Base, BaseItem, ItemConflictError, ItemNotFoundError, QueryResponse
__all__ = (
"Base",
"BaseItem",
- "FetchResponse",
"ItemConflictError",
"ItemNotFoundError",
+ "QueryResponse",
)
__version__ = "1.0.0"
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index da39f27..c9435a5 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -59,7 +59,7 @@ def __init__(self, key: str, *args: object) -> None:
super().__init__(f"key '{key}' not found", *args)
-class FetchResponse(BaseModel, Generic[ItemT]):
+class QueryResponse(BaseModel, Generic[ItemT]):
count: int = 0
last_key: Union[str, None] = None # noqa: UP007 Pydantic doesn't support `X | Y` syntax in Python 3.9.
items: Sequence[ItemT] = []
@@ -398,7 +398,7 @@ def query(
*queries: QueryType,
limit: int = 1000,
last: str | None = None,
- ) -> FetchResponse[ItemT]:
+ ) -> QueryResponse[ItemT]:
"""fetch items from the database.
`query` is an optional filter or list of filters. Without filter, it will return the whole db.
"""
@@ -416,12 +416,11 @@ def query(
response.raise_for_status()
except HTTPStatusError as exc:
raise ApiError(exc.response.text) from exc
+ query_response = QueryResponse[ItemT].model_validate_json(response.content)
if self.item_type:
- fetch_response = FetchResponse[ItemT].model_validate_json(response.content)
# HACK: Pydantic model_validate_json doesn't validate Sequence[ItemT] properly. # noqa: FIX004
- fetch_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(fetch_response.items)
- return fetch_response
- return response.json(cls=self.json_decoder)
+ query_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(query_response.items)
+ return query_response
@deprecated("This method has been renamed to `query` and will be removed in a future release.")
def fetch(
@@ -429,5 +428,5 @@ def fetch(
*queries: QueryType,
limit: int = 1000,
last: str | None = None,
- ) -> FetchResponse[ItemT]:
+ ) -> QueryResponse[ItemT]:
return self.query(*queries, limit=limit, last=last)
From 7892a423ec8084e50e9b7a1419ef4246c83a5561 Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Fri, 11 Oct 2024 12:06:17 -0700
Subject: [PATCH 26/29] Improve tests and exceptions
---
src/contiguity_base/__init__.py | 3 +-
src/contiguity_base/base.py | 122 ++++++++++++-----------
tests/test_base_dict.py | 117 ----------------------
tests/test_base_dict_typed.py | 171 ++++++++++++++++++++++++++++++++
tests/test_base_dict_untyped.py | 165 ++++++++++++++++++++++++++++++
tests/test_base_model.py | 71 +++++++++++--
tests/test_base_typeddict.py | 108 ++++++++++++++++----
7 files changed, 549 insertions(+), 208 deletions(-)
delete mode 100644 tests/test_base_dict.py
create mode 100644 tests/test_base_dict_typed.py
create mode 100644 tests/test_base_dict_untyped.py
diff --git a/src/contiguity_base/__init__.py b/src/contiguity_base/__init__.py
index a4318eb..e70cb0f 100644
--- a/src/contiguity_base/__init__.py
+++ b/src/contiguity_base/__init__.py
@@ -1,8 +1,9 @@
-from .base import Base, BaseItem, ItemConflictError, ItemNotFoundError, QueryResponse
+from .base import Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
__all__ = (
"Base",
"BaseItem",
+ "InvalidKeyError",
"ItemConflictError",
"ItemNotFoundError",
"QueryResponse",
diff --git a/src/contiguity_base/base.py b/src/contiguity_base/base.py
index c9435a5..980b3da 100644
--- a/src/contiguity_base/base.py
+++ b/src/contiguity_base/base.py
@@ -1,6 +1,6 @@
# TODO @lemonyte: todo list. # noqa: TD003, FIX002
# - [ ] new docstrings
-# - [ ] more tests
+# - [ ] test expiring items
# - [ ] support dataclasses
# - [ ] support models for queries
# - [ ] examples
@@ -51,7 +51,8 @@ class BaseItem(BaseModel):
class ItemConflictError(ApiError):
- pass
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"item with key '{key}' already exists", *args)
class ItemNotFoundError(ApiError):
@@ -59,48 +60,17 @@ def __init__(self, key: str, *args: object) -> None:
super().__init__(f"key '{key}' not found", *args)
+class InvalidKeyError(ValueError):
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"invalid key '{key}'", *args)
+
+
class QueryResponse(BaseModel, Generic[ItemT]):
count: int = 0
last_key: Union[str, None] = None # noqa: UP007 Pydantic doesn't support `X | Y` syntax in Python 3.9.
items: Sequence[ItemT] = []
-class _UpdatePayload(BaseModel):
- set: dict[str, DataType] = {}
- increment: dict[str, int] = {}
- append: dict[str, Sequence[DataType]] = {}
- prepend: dict[str, Sequence[DataType]] = {}
- delete: list[str] = []
-
- @classmethod
- def from_updates_mapping(cls: type[Self], updates: Mapping[str, DataType | _UpdateOperation], /) -> Self:
- set = {}
- increment = {}
- append = {}
- prepend = {}
- delete = []
- for attr, value in updates.items():
- if isinstance(value, _UpdateOperation):
- if isinstance(value, _Trim):
- delete.append(attr)
- elif isinstance(value, _Increment):
- increment[attr] = value.value
- # Prepend must be checked before Append because it's a subclass of Append.
- elif isinstance(value, _Prepend):
- prepend[attr] = value.value
- elif isinstance(value, _Append):
- append[attr] = value.value
- else:
- set[attr] = value
- return cls(
- set=set,
- increment=increment,
- append=append,
- prepend=prepend,
- delete=delete,
- )
-
-
class _UpdateOperation:
pass
@@ -144,6 +114,48 @@ def prepend(value: DataType, /) -> _Prepend:
return _Prepend(value)
+class _UpdatePayload(BaseModel):
+ set: dict[str, DataType] = {}
+ increment: dict[str, int] = {}
+ append: dict[str, Sequence[DataType]] = {}
+ prepend: dict[str, Sequence[DataType]] = {}
+ delete: list[str] = []
+
+ @classmethod
+ def from_updates_mapping(cls: type[Self], updates: Mapping[str, DataType | _UpdateOperation], /) -> Self:
+ set = {}
+ increment = {}
+ append = {}
+ prepend = {}
+ delete = []
+ for attr, value in updates.items():
+ if isinstance(value, _UpdateOperation):
+ if isinstance(value, _Trim):
+ delete.append(attr)
+ elif isinstance(value, _Increment):
+ increment[attr] = value.value
+ # Prepend must be checked before Append because it's a subclass of Append.
+ elif isinstance(value, _Prepend):
+ prepend[attr] = value.value
+ elif isinstance(value, _Append):
+ append[attr] = value.value
+ else:
+ set[attr] = value
+ return cls(
+ set=set,
+ increment=increment,
+ append=append,
+ prepend=prepend,
+ delete=delete,
+ )
+
+
+def _check_key(key: str, /) -> str:
+ if not key:
+ raise InvalidKeyError(key)
+ return quote(key, safe="")
+
+
class Base(Generic[ItemT]):
EXPIRES_ATTRIBUTE = "__expires"
PUT_LIMIT = 30
@@ -191,7 +203,7 @@ def __init__( # noqa: PLR0913
json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a Pydantic model.
) -> None:
if not name:
- msg = f"invalid name '{name}'"
+ msg = f"invalid Base name '{name}'"
raise ValueError(msg)
self.name = name
@@ -271,17 +283,19 @@ def _insert_expires_attr(
def get(self: Self, key: str, /) -> ItemT | None: ...
@overload
- def get(self: Self, key: str, default: ItemT, /) -> ItemT: ...
+ def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ...
@overload
- def get(self: Self, key: str, default: DefaultItemT, /) -> ItemT | DefaultItemT: ...
-
- def get(self: Self, key: str, default: ItemT | DefaultItemT | _Unset = _UNSET, /) -> ItemT | DefaultItemT | None:
- if not key:
- msg = f"invalid key '{key}'"
- raise ValueError(msg)
+ def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ...
- key = quote(key, safe="")
+ def get(
+ self: Self,
+ key: str,
+ /,
+ *,
+ default: ItemT | DefaultItemT | _Unset = _UNSET,
+ ) -> ItemT | DefaultItemT | None:
+ key = _check_key(key)
response = self._client.get(f"/items/{key}")
if response.status_code == HTTPStatus.NOT_FOUND:
if not isinstance(default, _Unset):
@@ -297,11 +311,7 @@ def get(self: Self, key: str, default: ItemT | DefaultItemT | _Unset = _UNSET, /
def delete(self: Self, key: str, /) -> None:
"""Delete an item from the Base."""
- if not key:
- msg = f"invalid key '{key}'"
- raise ValueError(msg)
-
- key = quote(key, safe="")
+ key = _check_key(key)
response = self._client.delete(f"/items/{key}")
try:
response.raise_for_status()
@@ -320,8 +330,7 @@ def insert(
response = self._client.post("/items", json={"item": item_dict})
if response.status_code == HTTPStatus.CONFLICT:
- msg = f"item with key '{item_dict.get('key')}' already exists"
- raise ItemConflictError(msg)
+ raise ItemConflictError(str(item_dict.get("key")))
if not (returned_item := self._response_as_item_type(response, sequence=True)):
msg = "expected a single item, got an empty response"
@@ -372,9 +381,7 @@ def update(
`updates` specifies the attribute names and values to update,add or remove
`key` is the key of the item to be updated
"""
- if not key:
- msg = f"invalid key '{key}'"
- raise ValueError(msg)
+ key = _check_key(key)
if not updates:
msg = "no updates provided"
raise ValueError(msg)
@@ -386,7 +393,6 @@ def update(
expire_at=expire_at,
)
- key = quote(key, safe="")
response = self._client.patch(f"/items/{key}", json={"updates": payload.model_dump()})
if response.status_code == HTTPStatus.NOT_FOUND:
raise ItemNotFoundError(key)
diff --git a/tests/test_base_dict.py b/tests/test_base_dict.py
deleted file mode 100644
index d518a63..0000000
--- a/tests/test_base_dict.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# ruff: noqa: S101, PLR2004
-from collections.abc import Generator, Mapping
-from typing import Any
-
-import pytest
-from dotenv import load_dotenv
-from pydantic import JsonValue
-
-from contiguity_base.base import Base, FetchResponse, ItemConflictError
-from tests import random_string
-
-load_dotenv()
-
-DictItemType = Mapping[str, JsonValue]
-
-
-def create_test_item(key: str = "test_key", /) -> DictItemType:
- return {
- "key": key,
- "field1": random_string(),
- "field2": random_string(),
- "field3": 1,
- "field4": 0,
- "field5": ["foo", "bar"],
- "field6": [1, 2],
- "field7": {"foo": "bar"},
- }
-
-
-@pytest.fixture
-def base() -> Generator[Base[DictItemType], Any, None]:
- base = Base("test_base_dict", item_type=DictItemType)
- for item in base.query().items:
- base.delete(str(item["key"]))
- yield base
- for item in base.query().items:
- base.delete(str(item["key"]))
-
-
-def test_get(base: Base[DictItemType]) -> None:
- item = create_test_item()
- base.insert(item)
- fetched_item = base.get("test_key")
- assert fetched_item == item
-
-
-def test_get_nonexistent(base: Base[DictItemType]) -> None:
- with pytest.warns(DeprecationWarning):
- assert base.get("nonexistent_key") is None
-
-
-def test_delete(base: Base[DictItemType]) -> None:
- item = create_test_item()
- base.insert(item)
- base.delete("test_key")
- with pytest.warns(DeprecationWarning):
- assert base.get("test_key") is None
-
-
-def test_insert(base: Base[DictItemType]) -> None:
- item = create_test_item()
- inserted_item = base.insert(item)
- assert inserted_item == item
-
-
-def test_insert_existing(base: Base[DictItemType]) -> None:
- item = create_test_item()
- base.insert(item)
- with pytest.raises(ItemConflictError):
- base.insert(item)
-
-
-def test_put(base: Base[DictItemType]) -> None:
- items = [create_test_item(f"test_key_{i}") for i in range(3)]
- response = base.put(*items)
- assert response == items
-
-
-def test_update(base: Base[DictItemType]) -> None:
- item = {
- "key": "test_key",
- "field1": random_string(),
- "field2": random_string(),
- "field3": 1,
- "field4": 0,
- "field5": ["foo", "bar"],
- "field6": [1, 2],
- "field7": {"foo": "bar"},
- }
- base.insert(item)
- updated_item = base.update(
- {
- "field1": "updated_value",
- "field2": base.util.trim(),
- "field3": base.util.increment(2),
- "field4": base.util.increment(-2),
- "field5": base.util.append("baz"),
- "field6": base.util.prepend([3, 4]),
- },
- key="test_key",
- )
- assert updated_item == {
- "key": "test_key",
- "field1": "updated_value",
- "field3": item["field3"] + 2,
- "field4": item["field4"] - 2,
- "field5": [*item["field5"], "baz"],
- "field6": [3, 4, *item["field6"]],
- "field7": item["field7"],
- }
-
-
-def test_query(base: Base[DictItemType]) -> None:
- items = [{"key": f"test_key_{i}", "field1": f"test_value_{i}"} for i in range(3)]
- base.put(*items)
- response = base.query()
- assert response == FetchResponse(count=3, last_key=None, items=items)
diff --git a/tests/test_base_dict_typed.py b/tests/test_base_dict_typed.py
new file mode 100644
index 0000000..1102c37
--- /dev/null
+++ b/tests/test_base_dict_typed.py
@@ -0,0 +1,171 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import Generator, Mapping
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+DictItemType = Mapping[str, JsonValue]
+
+
+def create_test_item(**kwargs: JsonValue) -> DictItemType:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+def base() -> Generator[Base[DictItemType], Any, None]:
+ base = Base("test_base_dict_typed", item_type=DictItemType)
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+ yield base
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=DictItemType)
+
+
+def test_bad_key(base: Base[DictItemType]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[DictItemType]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base[DictItemType]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[DictItemType]) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base[DictItemType]) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+def test_update_nonexistent(base: Base[DictItemType]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[DictItemType]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(
+ count=3,
+ last_key=None,
+ items=[item for item in items if isinstance(item["field1"], int) and item["field1"] > 1],
+ )
diff --git a/tests/test_base_dict_untyped.py b/tests/test_base_dict_untyped.py
new file mode 100644
index 0000000..9b144cc
--- /dev/null
+++ b/tests/test_base_dict_untyped.py
@@ -0,0 +1,165 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import Generator
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+
+def create_test_item(**kwargs: JsonValue) -> dict:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+def base() -> Generator[Base, Any, None]:
+ base = Base("test_base_dict_untyped")
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+ yield base
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("")
+
+
+def test_bad_key(base: Base) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base) -> None:
+ item = create_test_item()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+def test_update_nonexistent(base: Base) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
diff --git a/tests/test_base_model.py b/tests/test_base_model.py
index 9829ed4..21e936a 100644
--- a/tests/test_base_model.py
+++ b/tests/test_base_model.py
@@ -1,4 +1,5 @@
-# ruff: noqa: S101, PLR2004
+# ruff: noqa: S101, S311, PLR2004
+import random
from collections.abc import Generator
from typing import Any
@@ -6,7 +7,7 @@
from dotenv import load_dotenv
from pydantic import BaseModel
-from contiguity_base.base import Base, FetchResponse, ItemConflictError
+from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
from tests import random_string
load_dotenv()
@@ -14,7 +15,7 @@
class TestItemModel(BaseModel):
key: str = "test_key"
- field1: str = random_string()
+ field1: int = random.randint(1, 1000)
field2: str = random_string()
field3: int = 1
field4: int = 0
@@ -33,6 +34,20 @@ def base() -> Generator[Base[TestItemModel], Any, None]:
base.delete(item.key)
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=TestItemModel)
+
+
+def test_bad_key(base: Base[TestItemModel]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
def test_get(base: Base[TestItemModel]) -> None:
item = TestItemModel()
base.insert(item)
@@ -45,6 +60,12 @@ def test_get_nonexistent(base: Base[TestItemModel]) -> None:
assert base.get("nonexistent_key") is None
+def test_get_default(base: Base[TestItemModel]) -> None:
+ for default_item in (None, "foo", 42, TestItemModel()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
def test_delete(base: Base[TestItemModel]) -> None:
item = TestItemModel()
base.insert(item)
@@ -68,17 +89,30 @@ def test_insert_existing(base: Base[TestItemModel]) -> None:
def test_put(base: Base[TestItemModel]) -> None:
items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[TestItemModel]) -> None:
+ items = []
response = base.put(*items)
assert response == items
+def test_put_too_many(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
def test_update(base: Base[TestItemModel]) -> None:
item = TestItemModel()
base.insert(item)
updated_item = base.update(
{
- "field1": "updated_value",
- "field2": base.util.trim(),
+ "field1": base.util.trim(),
+ "field2": "updated_value",
"field3": base.util.increment(2),
"field4": base.util.increment(-2),
"field5": base.util.append("baz"),
@@ -88,8 +122,8 @@ def test_update(base: Base[TestItemModel]) -> None:
)
assert updated_item == TestItemModel(
key="test_key",
- field1="updated_value",
- field2=updated_item.field2,
+ field1=updated_item.field1,
+ field2="updated_value",
field3=item.field3 + 2,
field4=item.field4 - 2,
field5=[*item.field5, "baz"],
@@ -98,8 +132,25 @@ def test_update(base: Base[TestItemModel]) -> None:
)
-def test_query(base: Base[TestItemModel]) -> None:
- items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
+def test_update_nonexistent(base: Base[TestItemModel]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[TestItemModel]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
base.put(*items)
response = base.query()
- assert response == FetchResponse(count=3, last_key=None, items=items)
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item.field1 > 1])
diff --git a/tests/test_base_typeddict.py b/tests/test_base_typeddict.py
index 9026861..3b7681f 100644
--- a/tests/test_base_typeddict.py
+++ b/tests/test_base_typeddict.py
@@ -1,20 +1,25 @@
-# ruff: noqa: S101, PLR2004
-from collections.abc import Generator
-from typing import Any
+# ruff: noqa: S101, S311, PLR2004
+from __future__ import annotations
+
+import random
+from typing import TYPE_CHECKING, Any
import pytest
from dotenv import load_dotenv
from typing_extensions import TypedDict
-from contiguity_base.base import Base, FetchResponse, ItemConflictError
+from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
from tests import random_string
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
load_dotenv()
class TestItemDict(TypedDict):
key: str
- field1: str
+ field1: int
field2: str
field3: int
field4: int
@@ -23,17 +28,26 @@ class TestItemDict(TypedDict):
field7: dict[str, str]
-def create_test_item(key: str = "test_key", /) -> TestItemDict:
- return {
- "key": key,
- "field1": random_string(),
- "field2": random_string(),
- "field3": 1,
- "field4": 0,
- "field5": ["foo", "bar"],
- "field6": [1, 2],
- "field7": {"foo": "bar"},
- }
+def create_test_item( # noqa: PLR0913
+ key: str = "test_key",
+ field1: int = random.randint(1, 1000),
+ field2: str = random_string(),
+ field3: int = 1,
+ field4: int = 0,
+ field5: list[str] | None = None,
+ field6: list[int] | None = None,
+ field7: dict[str, str] | None = None,
+) -> TestItemDict:
+ return TestItemDict(
+ key=key,
+ field1=field1,
+ field2=field2,
+ field3=field3,
+ field4=field4,
+ field5=field5 or ["foo", "bar"],
+ field6=field6 or [1, 2],
+ field7=field7 or {"foo": "bar"},
+ )
@pytest.fixture
@@ -46,6 +60,20 @@ def base() -> Generator[Base[TestItemDict], Any, None]:
base.delete(item["key"])
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=TestItemDict)
+
+
+def test_bad_key(base: Base[TestItemDict]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
def test_get(base: Base[TestItemDict]) -> None:
item = create_test_item()
base.insert(item)
@@ -58,6 +86,12 @@ def test_get_nonexistent(base: Base[TestItemDict]) -> None:
assert base.get("nonexistent_key") is None
+def test_get_default(base: Base[TestItemDict]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
def test_delete(base: Base[TestItemDict]) -> None:
item = create_test_item()
base.insert(item)
@@ -81,18 +115,31 @@ def test_insert_existing(base: Base[TestItemDict]) -> None:
def test_put(base: Base[TestItemDict]) -> None:
items = [create_test_item(f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[TestItemDict]) -> None:
+ items = []
response = base.put(*items)
assert response == items
+def test_put_too_many(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
def test_update(base: Base[TestItemDict]) -> None:
item = create_test_item()
base.insert(item)
updated_item = base.update(
{
- "field1": "updated_value",
# Trim will not pass type validation when using TypedDict
# because TypedDict does not support default values.
+ "field2": "updated_value",
"field3": base.util.increment(2),
"field4": base.util.increment(-2),
"field5": base.util.append("baz"),
@@ -102,8 +149,8 @@ def test_update(base: Base[TestItemDict]) -> None:
)
assert updated_item == TestItemDict(
key="test_key",
- field1="updated_value",
- field2=item["field2"],
+ field1=item["field1"],
+ field2="updated_value",
field3=item["field3"] + 2,
field4=item["field4"] - 2,
field5=[*item["field5"], "baz"],
@@ -112,8 +159,25 @@ def test_update(base: Base[TestItemDict]) -> None:
)
-def test_query(base: Base[TestItemDict]) -> None:
- items = [create_test_item(f"test_key_{i}") for i in range(3)]
+def test_update_nonexistent(base: Base[TestItemDict]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[TestItemDict]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
base.put(*items)
response = base.query()
- assert response == FetchResponse(count=3, last_key=None, items=items)
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
From 90e81af8ca4c85973408007e865c9ab09955f39a Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Fri, 11 Oct 2024 12:06:40 -0700
Subject: [PATCH 27/29] Add pytest coverage
---
pyproject.toml | 1 +
uv.lock | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 5b2afa6..0fef85e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,6 +49,7 @@ version = {attr = "contiguity_base.__version__"}
dev-dependencies = [
"pre-commit~=3.8.0",
"pytest~=8.3.3",
+ "pytest-cov~=5.0.0",
"python-dotenv~=1.0.1",
]
diff --git a/uv.lock b/uv.lock
index ea99f0d..0d5fe20 100644
--- a/uv.lock
+++ b/uv.lock
@@ -70,6 +70,7 @@ dependencies = [
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
+ { name = "pytest-cov" },
{ name = "python-dotenv" },
]
@@ -84,9 +85,84 @@ requires-dist = [
dev = [
{ name = "pre-commit", specifier = "~=3.8.0" },
{ name = "pytest", specifier = "~=8.3.3" },
+ { name = "pytest-cov", specifier = "~=5.0.0" },
{ name = "python-dotenv", specifier = "~=1.0.1" },
]
+[[package]]
+name = "coverage"
+version = "7.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 },
+ { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 },
+ { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 },
+ { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 },
+ { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 },
+ { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 },
+ { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 },
+ { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 },
+ { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 },
+ { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 },
+ { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 },
+ { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 },
+ { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 },
+ { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 },
+ { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 },
+ { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 },
+ { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 },
+ { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 },
+ { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 },
+ { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 },
+ { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 },
+ { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 },
+ { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 },
+ { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 },
+ { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 },
+ { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 },
+ { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 },
+ { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 },
+ { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 },
+ { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 },
+ { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 },
+ { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 },
+ { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 },
+ { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 },
+ { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 },
+ { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 },
+ { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 },
+ { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 },
+ { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 },
+ { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 },
+ { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 },
+ { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 },
+ { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 },
+ { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 },
+ { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 },
+ { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 },
+ { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 },
+ { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 },
+ { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 },
+ { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 },
+ { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 },
+ { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 },
+ { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 },
+ { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 },
+ { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 },
+ { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 },
+ { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 },
+ { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 },
+ { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 },
+ { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
[[package]]
name = "distlib"
version = "0.3.8"
@@ -349,6 +425,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
]
+[[package]]
+name = "pytest-cov"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 },
+]
+
[[package]]
name = "python-dotenv"
version = "1.0.1"
From 050ebbe407428d033f145214d7d37ee1345be60c Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Fri, 11 Oct 2024 12:42:10 -0700
Subject: [PATCH 28/29] Update examples
---
examples/basic_usage.py | 63 +++++++++++++++-------------------
examples/pydantic_usage.py | 69 ++++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 36 deletions(-)
create mode 100644 examples/pydantic_usage.py
diff --git a/examples/basic_usage.py b/examples/basic_usage.py
index 777671e..a3805e4 100644
--- a/examples/basic_usage.py
+++ b/examples/basic_usage.py
@@ -1,49 +1,40 @@
-# ruff: noqa: T201, BLE001, ANN401
-from typing import Any
-
+# ruff: noqa: T201
from contiguity_base import Base
-# Create a Base instance
-db = Base("randomBase9000")
-
-
-# Helper function to print results
-def print_result(operation: str, result: Any) -> None:
- print(f"{operation} result: {result}")
- if result is None:
- print(f"Warning: {operation} operation failed!")
+# Create a Base instance.
+db = Base("my-base")
+# Put an item with a specific key.
+put_result = db.put({"key": "foo", "value": "Hello world!"})
+print("Put result:", put_result)
-# Put an item with a specific key
-result1 = db.put({"key": "my-key-py", "value": "Hello world!"})
-print_result("Put", result1)
+# Put multiple items.
+put_result = db.put({"key": "bar", "value": "Bar"}, {"key": "baz", "value": "Baz"})
+print("Put many result:", put_result)
-# Insert an item with a specific key
-result2 = db.insert({"key": "john-doe-py", "name": "John Doe", "age": 30})
-print_result("Insert", result2)
+# Insert an item with a specific key.
+insert_result = db.insert({"key": "john-doe", "name": "John Doe", "age": 30})
+print("Insert result:", insert_result)
-# Insert an item with a specific key and expireIn
-result3 = db.insert({"key": "jane-doe-py", "name": "Jane Doe", "age": 28}, expire_in=3600)
-print_result("Insert with expireIn", result3)
+# Insert an item with a specific key that expires in 1 hour.
+expiring_insert_result = db.insert({"key": "jane-doe", "name": "Jane Doe", "age": 28}, expire_in=3600)
+print("Insert with expiry result:", expiring_insert_result)
-# Get an item
-get_result = db.get("john-doe-py")
-print_result("Get", get_result)
+# Get an item using a key.
+get_result = db.get("foo")
+print("Get result:", get_result)
-# Update an item
-update_result = db.update({"age": 31}, key="john-doe-py")
-print_result("Update", update_result)
+# Update an item.
+update_result = db.update({"age": db.util.increment(2), "name": "Mr. Doe"}, key="john-doe")
+print("Update result:", update_result)
-# Fetch items
-try:
- fetch_result = db.query({"age": {"$gt": 25}}, limit=10)
- print_result("Fetch", fetch_result)
-except Exception as exc:
- print(f"Fetch operation failed: {exc}")
+# Query items.
+query_result = db.query({"age?gt": 25}, limit=10)
+print("Query result:", query_result)
-# Delete an item
-delete_result = db.delete("jane-doe-py")
+# Delete an item.
+db.delete("jane-doe-py")
-# Delete everything
+# Delete all items.
for item in db.query().items:
db.delete(str(item["key"]))
diff --git a/examples/pydantic_usage.py b/examples/pydantic_usage.py
new file mode 100644
index 0000000..db9bd17
--- /dev/null
+++ b/examples/pydantic_usage.py
@@ -0,0 +1,69 @@
+# ruff: noqa: T201
+from pydantic import BaseModel
+
+from contiguity_base import Base
+
+
+# Create a Pydantic model for the item.
+class MyItem(BaseModel):
+ key: str # Make sure to include the key field.
+ name: str
+ age: int
+ interests: list[str] = []
+
+
+# Create a Base instance.
+# Static type checking will work with the Pydantic model.
+db = Base("members", item_type=MyItem)
+
+# Put an item with a specific key.
+put_result = db.put(
+ MyItem(
+ key="foo",
+ name="John Doe",
+ age=30,
+ interests=["Python", "JavaScript"],
+ ),
+)
+print("Put result:", put_result)
+
+# Put multiple items.
+put_result = db.put(
+ MyItem(key="bar", name="Jane Doe", age=28),
+ MyItem(key="baz", name="Alice", age=25),
+)
+print("Put many result:", put_result)
+
+# Insert an item with a specific key.
+insert_result = db.insert(
+ MyItem(
+ key="xyz",
+ name="Arthur",
+ age=33,
+ interests=["Anime", "Music"],
+ ),
+)
+print("Insert result:", insert_result)
+
+# Insert an item with a specific key that expires in 1 hour.
+expiring_insert_result = db.insert(MyItem(key="abc", name="David", age=20), expire_in=3600)
+print("Insert with expiry result:", expiring_insert_result)
+
+# Get an item using a key.
+get_result = db.get("foo")
+print("Get result:", get_result)
+
+# Update an item.
+update_result = db.update({"age": db.util.increment(2), "name": "Mr. Doe"}, key="foo")
+print("Update result:", update_result)
+
+# Query items.
+query_result = db.query({"age?gt": 25}, limit=10)
+print("Query result:", query_result)
+
+# Delete an item.
+db.delete("bar")
+
+# Delete all items.
+for item in db.query().items:
+ db.delete(str(item.key))
From df1bd04743fbbc18bcde5cd86c32ba51c0096e8b Mon Sep 17 00:00:00 2001
From: Lemonyte <49930425+lemonyte@users.noreply.github.com>
Date: Fri, 11 Oct 2024 12:44:52 -0700
Subject: [PATCH 29/29] pre-commit autoupdate
---
.pre-commit-config.yaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0e06ff5..9b8fb29 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.6.0
+ rev: v5.0.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
@@ -11,12 +11,12 @@ repos:
args: [--markdown-linebreak-ext=md]
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.6.8
+ rev: v0.6.9
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/RobertCraigie/pyright-python
- rev: v1.1.382.post1
+ rev: v1.1.384
hooks:
- id: pyright