Skip to content

Commit 5de0c57

Browse files
committed
Initial attempt to switch to msgspec
1 parent 9476e1e commit 5de0c57

22 files changed

+348
-529
lines changed
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
# ruff: noqa: T201
2-
from pydantic import BaseModel
2+
import msgspec
33

44
from contiguity import Base
55

66

7-
# Create a Pydantic model for the item.
8-
class MyItem(BaseModel):
7+
# Create a msgspec struct for the item.
8+
class MyItem(msgspec.Struct):
99
key: str # Make sure to include the key field.
1010
name: str
1111
age: int
1212
interests: list[str] = []
1313

1414

1515
# Create a Base instance.
16-
# Static type checking will work with the Pydantic model.
16+
# Static type checking will work with the msgspec struct.
1717
db = Base("members", item_type=MyItem)
1818

1919
# Put an item with a specific key.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ classifiers = [
3535
]
3636
dependencies = [
3737
"httpx>=0.27.2",
38+
"msgspec>=0.19.0",
3839
"phonenumbers>=8.13.47,<9.0.0",
39-
"pydantic>=2.9.0,<3.0.0",
4040
"typing-extensions>=4.12.2,<5.0.0",
4141
]
4242

@@ -67,6 +67,9 @@ target-version = "py39"
6767
select = ["ALL"]
6868
ignore = ["A", "D", "T201"]
6969

70+
[tool.ruff.lint.per-file-ignores]
71+
"tests/**" = ["S101"]
72+
7073
[tool.pyright]
7174
venvPath = "."
7275
venv = ".venv"

src/contiguity/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,20 @@ def login(token: str, /, *, debug: bool = False) -> Contiguity:
4747

4848

4949
__all__ = (
50-
"AsyncBase",
51-
"Contiguity",
52-
"Send",
53-
"Verify",
54-
"EmailAnalytics",
55-
"Quota",
5650
"OTP",
57-
"Template",
51+
"AsyncBase",
5852
"Base",
5953
"BaseItem",
54+
"Contiguity",
55+
"EmailAnalytics",
6056
"InvalidKeyError",
6157
"ItemConflictError",
6258
"ItemNotFoundError",
6359
"QueryResponse",
60+
"Quota",
61+
"Send",
62+
"Template",
63+
"Verify",
6464
"login",
6565
)
6666
__version__ = "2.0.0"

src/contiguity/_auth.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from __future__ import annotations
2-
31
import os
42

53

6-
def _get_env_var(var_name: str, friendly_name: str | None = None) -> str:
4+
def _get_env_var(var_name: str, friendly_name: "str | None" = None) -> str:
75
value = os.getenv(var_name, "")
86
if not value:
97
msg = f"no {friendly_name or var_name} provided"

src/contiguity/_client.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from __future__ import annotations
2-
31
from httpx import AsyncClient as HttpxAsyncClient
42
from httpx import Client as HttpxClient
53

@@ -12,10 +10,10 @@ class ApiError(Exception):
1210

1311
class ApiClient(HttpxClient):
1412
def __init__(
15-
self: ApiClient,
13+
self,
1614
*,
1715
base_url: str = "https://api.contiguity.co",
18-
api_key: str | None = None,
16+
api_key: "str | None" = None,
1917
timeout: int = 5,
2018
) -> None:
2119
if not api_key:
@@ -33,10 +31,10 @@ def __init__(
3331

3432
class AsyncApiClient(HttpxAsyncClient):
3533
def __init__(
36-
self: AsyncApiClient,
34+
self,
3735
*,
3836
base_url: str = "https://api.contiguity.co",
39-
api_key: str | None = None,
37+
api_key: "str | None" = None,
4038
timeout: int = 5,
4139
) -> None:
4240
if not api_key:

src/contiguity/_common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from pydantic import BaseModel
1+
import msgspec
22

33

4-
class Crumbs(BaseModel):
4+
class Crumbs(msgspec.Struct):
55
plan: str
66
quota: int
77
type: str

src/contiguity/base/async_base.py

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88
from typing import TYPE_CHECKING, Generic, Literal, overload
99
from warnings import warn
1010

11+
import msgspec
1112
from httpx import HTTPStatusError
12-
from pydantic import BaseModel, TypeAdapter
13-
from pydantic import JsonValue as DataType
1413
from typing_extensions import deprecated
1514

1615
from contiguity._auth import get_data_key, get_project_id
1716
from contiguity._client import ApiError, AsyncApiClient
1817

1918
from .common import (
2019
UNSET,
20+
DataType,
2121
DefaultItemT,
2222
ItemT,
2323
QueryResponse,
@@ -33,7 +33,6 @@
3333

3434
if TYPE_CHECKING:
3535
from httpx import Response as HttpxResponse
36-
from typing_extensions import Self
3736

3837

3938
class AsyncBase(Generic[ItemT]):
@@ -42,7 +41,7 @@ class AsyncBase(Generic[ItemT]):
4241

4342
@overload
4443
def __init__(
45-
self: Self,
44+
self,
4645
name: str,
4746
/,
4847
*,
@@ -57,7 +56,7 @@ def __init__(
5756
@overload
5857
@deprecated("The `project_key` parameter has been renamed to `data_key`.")
5958
def __init__(
60-
self: Self,
59+
self,
6160
name: str,
6261
/,
6362
*,
@@ -70,7 +69,7 @@ def __init__(
7069
) -> None: ...
7170

7271
def __init__( # noqa: PLR0913
73-
self: Self,
72+
self,
7473
name: str,
7574
/,
7675
*,
@@ -80,7 +79,7 @@ def __init__( # noqa: PLR0913
8079
project_id: str | None = None,
8180
host: str | None = None,
8281
api_version: str = "v1",
83-
json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a Pydantic model.
82+
json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a msgspec struct.
8483
) -> None:
8584
if not name:
8685
msg = f"invalid Base name '{name}'"
@@ -102,23 +101,23 @@ def __init__( # noqa: PLR0913
102101

103102
@overload
104103
def _response_as_item_type(
105-
self: Self,
104+
self,
106105
response: HttpxResponse,
107106
/,
108107
*,
109108
sequence: Literal[False] = False,
110109
) -> ItemT: ...
111110
@overload
112111
def _response_as_item_type(
113-
self: Self,
112+
self,
114113
response: HttpxResponse,
115114
/,
116115
*,
117116
sequence: Literal[True] = True,
118117
) -> Sequence[ItemT]: ...
119118

120119
def _response_as_item_type(
121-
self: Self,
120+
self,
122121
response: HttpxResponse,
123122
/,
124123
*,
@@ -130,12 +129,12 @@ def _response_as_item_type(
130129
raise ApiError(exc.response.text) from exc
131130
if self.item_type:
132131
if sequence:
133-
return TypeAdapter(Sequence[self.item_type]).validate_json(response.content)
134-
return TypeAdapter(self.item_type).validate_json(response.content)
132+
return msgspec.json.decode(response.content, type=Sequence[self.item_type])
133+
return msgspec.json.decode(response.content, type=self.item_type)
135134
return response.json(cls=self.json_decoder)
136135

137136
def _insert_expires_attr(
138-
self: Self,
137+
self,
139138
item: ItemT | Mapping[str, DataType],
140139
expire_in: int | None = None,
141140
expire_at: TimestampType | None = None,
@@ -144,7 +143,7 @@ def _insert_expires_attr(
144143
msg = "cannot use both expire_in and expire_at"
145144
raise ValueError(msg)
146145

147-
item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
146+
item_dict = msgspec.structs.asdict(item) if isinstance(item, msgspec.Struct) else dict(item)
148147

149148
if not expire_in and not expire_at:
150149
return item_dict
@@ -160,16 +159,16 @@ def _insert_expires_attr(
160159
return item_dict
161160

162161
@overload
163-
async def get(self: Self, key: str, /) -> ItemT | None: ...
162+
async def get(self, key: str, /) -> ItemT | None: ...
164163

165164
@overload
166-
async def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ...
165+
async def get(self, key: str, /, *, default: ItemT) -> ItemT: ...
167166

168167
@overload
169-
async def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ...
168+
async def get(self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ...
170169

171170
async def get(
172-
self: Self,
171+
self,
173172
key: str,
174173
/,
175174
*,
@@ -189,7 +188,7 @@ async def get(
189188

190189
return self._response_as_item_type(response, sequence=False)
191190

192-
async def delete(self: Self, key: str, /) -> None:
191+
async def delete(self, key: str, /) -> None:
193192
"""Delete an item from the Base."""
194193
key = check_key(key)
195194
response = await self._client.delete(f"/items/{key}")
@@ -199,7 +198,7 @@ async def delete(self: Self, key: str, /) -> None:
199198
raise ApiError(exc.response.text) from exc
200199

201200
async def insert(
202-
self: Self,
201+
self,
203202
item: ItemT,
204203
/,
205204
*,
@@ -218,7 +217,7 @@ async def insert(
218217
return returned_item[0]
219218

220219
async def put(
221-
self: Self,
220+
self,
222221
*items: ItemT,
223222
expire_in: int | None = None,
224223
expire_at: TimestampType | None = None,
@@ -239,7 +238,7 @@ async def put(
239238

240239
@deprecated("This method will be removed in a future release. You can pass multiple items to `put`.")
241240
async def put_many(
242-
self: Self,
241+
self,
243242
items: Sequence[ItemT],
244243
/,
245244
*,
@@ -249,7 +248,7 @@ async def put_many(
249248
return await self.put(*items, expire_in=expire_in, expire_at=expire_at)
250249

251250
async def update(
252-
self: Self,
251+
self,
253252
updates: Mapping[str, DataType | UpdateOperation],
254253
/,
255254
*,
@@ -273,14 +272,14 @@ async def update(
273272
expire_at=expire_at,
274273
)
275274

276-
response = await self._client.patch(f"/items/{key}", json={"updates": payload.model_dump()})
275+
response = await self._client.patch(f"/items/{key}", json={"updates": msgspec.structs.asdict(payload)})
277276
if response.status_code == HTTPStatus.NOT_FOUND:
278277
raise ItemNotFoundError(key)
279278

280279
return self._response_as_item_type(response, sequence=False)
281280

282281
async def query(
283-
self: Self,
282+
self,
284283
*queries: QueryType,
285284
limit: int = 1000,
286285
last: str | None = None,
@@ -302,15 +301,11 @@ async def query(
302301
response.raise_for_status()
303302
except HTTPStatusError as exc:
304303
raise ApiError(exc.response.text) from exc
305-
query_response = QueryResponse[ItemT].model_validate_json(response.content)
306-
if self.item_type:
307-
# HACK: Pydantic model_validate_json doesn't validate Sequence[ItemT] properly. # noqa: FIX004
308-
query_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(query_response.items)
309-
return query_response
304+
return msgspec.json.decode(response.content, type=QueryResponse[ItemT])
310305

311306
@deprecated("This method has been renamed to `query` and will be removed in a future release.")
312307
async def fetch(
313-
self: Self,
308+
self,
314309
*queries: QueryType,
315310
limit: int = 1000,
316311
last: str | None = None,

0 commit comments

Comments
 (0)