semble-api 0.0.1__tar.gz → 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {semble_api-0.0.1 → semble_api-0.0.2}/PKG-INFO +21 -8
  2. {semble_api-0.0.1 → semble_api-0.0.2}/README.md +19 -4
  3. semble_api-0.0.2/docs/agent-surfaces.md +74 -0
  4. {semble_api-0.0.1 → semble_api-0.0.2}/pyproject.toml +2 -4
  5. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/_client.py +62 -22
  6. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/types.py +2 -4
  7. {semble_api-0.0.1 → semble_api-0.0.2}/tests/test_client.py +23 -0
  8. {semble_api-0.0.1 → semble_api-0.0.2}/uv.lock +5 -475
  9. {semble_api-0.0.1 → semble_api-0.0.2}/.gitignore +0 -0
  10. {semble_api-0.0.1 → semble_api-0.0.2}/.pre-commit-config.yaml +0 -0
  11. {semble_api-0.0.1 → semble_api-0.0.2}/LICENSE +0 -0
  12. {semble_api-0.0.1 → semble_api-0.0.2}/justfile +0 -0
  13. {semble_api-0.0.1 → semble_api-0.0.2}/scripts/roundtrip.py +0 -0
  14. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/__init__.py +0 -0
  15. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/_exceptions.py +0 -0
  16. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/_utils.py +0 -0
  17. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/cli.py +0 -0
  18. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/mcp.py +0 -0
  19. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/py.typed +0 -0
  20. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/records.py +0 -0
  21. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/__init__.py +0 -0
  22. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/_base.py +0 -0
  23. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/actors.py +0 -0
  24. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/cards.py +0 -0
  25. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/collections.py +0 -0
  26. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/connections.py +0 -0
  27. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/feeds.py +0 -0
  28. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/graph.py +0 -0
  29. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/notifications.py +0 -0
  30. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/resources/search.py +0 -0
  31. {semble_api-0.0.1 → semble_api-0.0.2}/src/semble/settings.py +0 -0
  32. {semble_api-0.0.1 → semble_api-0.0.2}/tests/__init__.py +0 -0
  33. {semble_api-0.0.1 → semble_api-0.0.2}/tests/conftest.py +0 -0
  34. {semble_api-0.0.1 → semble_api-0.0.2}/tests/test_cli.py +0 -0
  35. {semble_api-0.0.1 → semble_api-0.0.2}/tests/test_mcp.py +0 -0
  36. {semble_api-0.0.1 → semble_api-0.0.2}/tests/test_resources.py +0 -0
  37. {semble_api-0.0.1 → semble_api-0.0.2}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semble-api
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: python client for the semble api
5
5
  Author-email: zzstoatzz <thrast36@gmail.com>
6
6
  License-Expression: MIT
@@ -10,14 +10,12 @@ Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.10
14
- Classifier: Programming Language :: Python :: 3.11
15
13
  Classifier: Programming Language :: Python :: 3.12
16
14
  Classifier: Programming Language :: Python :: 3.13
17
15
  Classifier: Programming Language :: Python :: 3.14
18
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
17
  Classifier: Typing :: Typed
20
- Requires-Python: >=3.10
18
+ Requires-Python: >=3.12
21
19
  Requires-Dist: httpx2>=2.3.0
22
20
  Requires-Dist: pydantic-settings>=2.14.1
23
21
  Requires-Dist: pydantic>=2.7
@@ -134,13 +132,27 @@ semble feed --pretty
134
132
 
135
133
  the `mcp` extra ships a `semble-mcp` entry point that exposes this sdk to mcp clients via [fastmcp code mode](https://gofastmcp.com/servers/transforms/code-mode): three meta-tools (`search` / `get_schema` / `execute`) instead of one tool per endpoint, with model-written python composing sdk calls in a [monty](https://github.com/pydantic/monty) sandbox. intermediate results stay in the sandbox; only the final answer returns to the model's context.
136
134
 
135
+ create an api key at [semble.so/settings/api-keys](https://semble.so/settings/api-keys), then:
136
+
137
137
  ```bash
138
- claude mcp add semble -- uvx --from 'semble-api[mcp]' semble-mcp
139
- # or from a checkout
140
- claude mcp add semble -- uv run --directory /path/to/this/repo semble-mcp
138
+ claude mcp add semble -e SEMBLE_API_KEY=your-key -- uvx --from 'semble-api[mcp]' semble-mcp
139
+ ```
140
+
141
+ for other mcp clients (claude desktop, cursor, ...), the equivalent json config:
142
+
143
+ ```json
144
+ {
145
+ "mcpServers": {
146
+ "semble": {
147
+ "command": "uvx",
148
+ "args": ["--from", "semble-api[mcp]", "semble-mcp"],
149
+ "env": { "SEMBLE_API_KEY": "your-key" }
150
+ }
151
+ }
152
+ }
141
153
  ```
142
154
 
143
- auth comes from `SEMBLE_API_KEY` (environment or `.env`); without a key the server is limited to public reads.
155
+ the key is optional — without it the server is limited to public reads. the server also picks up `SEMBLE_API_KEY` from the environment or a `.env` in the working directory, so inside a checkout of this repo a plain `claude mcp add semble -- uv run --directory /path/to/this/repo semble-mcp` works too.
144
156
 
145
157
  ## examples
146
158
 
@@ -160,6 +172,7 @@ just check # ty
160
172
 
161
173
  ## see also
162
174
 
175
+ - [semble for agents](docs/agent-surfaces.md) — choosing between the sdk, cli, and mcp surfaces when wiring up agents
163
176
  - [semble api docs](https://docs.cosmik.network/semble-api)
164
177
  - [@semble.so/api](https://npmx.dev/package/@semble.so/api) — official typescript client
165
178
  - [tangled.org/pdewey.com/semble](https://tangled.org/pdewey.com/semble) — go client
@@ -105,13 +105,27 @@ semble feed --pretty
105
105
 
106
106
  the `mcp` extra ships a `semble-mcp` entry point that exposes this sdk to mcp clients via [fastmcp code mode](https://gofastmcp.com/servers/transforms/code-mode): three meta-tools (`search` / `get_schema` / `execute`) instead of one tool per endpoint, with model-written python composing sdk calls in a [monty](https://github.com/pydantic/monty) sandbox. intermediate results stay in the sandbox; only the final answer returns to the model's context.
107
107
 
108
+ create an api key at [semble.so/settings/api-keys](https://semble.so/settings/api-keys), then:
109
+
108
110
  ```bash
109
- claude mcp add semble -- uvx --from 'semble-api[mcp]' semble-mcp
110
- # or from a checkout
111
- claude mcp add semble -- uv run --directory /path/to/this/repo semble-mcp
111
+ claude mcp add semble -e SEMBLE_API_KEY=your-key -- uvx --from 'semble-api[mcp]' semble-mcp
112
+ ```
113
+
114
+ for other mcp clients (claude desktop, cursor, ...), the equivalent json config:
115
+
116
+ ```json
117
+ {
118
+ "mcpServers": {
119
+ "semble": {
120
+ "command": "uvx",
121
+ "args": ["--from", "semble-api[mcp]", "semble-mcp"],
122
+ "env": { "SEMBLE_API_KEY": "your-key" }
123
+ }
124
+ }
125
+ }
112
126
  ```
113
127
 
114
- auth comes from `SEMBLE_API_KEY` (environment or `.env`); without a key the server is limited to public reads.
128
+ the key is optional — without it the server is limited to public reads. the server also picks up `SEMBLE_API_KEY` from the environment or a `.env` in the working directory, so inside a checkout of this repo a plain `claude mcp add semble -- uv run --directory /path/to/this/repo semble-mcp` works too.
115
129
 
116
130
  ## examples
117
131
 
@@ -131,6 +145,7 @@ just check # ty
131
145
 
132
146
  ## see also
133
147
 
148
+ - [semble for agents](docs/agent-surfaces.md) — choosing between the sdk, cli, and mcp surfaces when wiring up agents
134
149
  - [semble api docs](https://docs.cosmik.network/semble-api)
135
150
  - [@semble.so/api](https://npmx.dev/package/@semble.so/api) — official typescript client
136
151
  - [tangled.org/pdewey.com/semble](https://tangled.org/pdewey.com/semble) — go client
@@ -0,0 +1,74 @@
1
+ # semble for agents
2
+
3
+ this package ships three surfaces for the same api: a python sdk, a cli, and an mcp server. they share one auth story (`SEMBLE_API_KEY` from the environment or a `.env`; without it, public reads only) and one underlying client — the differences are about *where your agent runs and what it can touch*. this doc is for people wiring semble into agents, not for people developing this repo.
4
+
5
+ ## at a glance
6
+
7
+ | surface | install | the agent is... |
8
+ | ------- | ------- | --------------- |
9
+ | sdk | `uv add semble-api` | python you're writing (scripts, pydantic-ai tools, pipelines) |
10
+ | cli | `uv add 'semble-api[cli]'` / `uvx` | something with a shell (claude code, codex, a cron job) |
11
+ | mcp | `claude mcp add semble -e SEMBLE_API_KEY=... -- uvx --from 'semble-api[mcp]' semble-mcp` | an mcp client, especially one without a shell (claude desktop, cursor) |
12
+
13
+ ## the sdk
14
+
15
+ the substrate everything else is built on. typed sync/async clients over all ~50 `network.cosmik.*` endpoints, pydantic models on every response, snake_case in / camelCase on the wire, and an escape hatch (`client.get(nsid, params)`) for anything unwrapped.
16
+
17
+ ```python
18
+ from semble import Semble
19
+
20
+ with Semble() as client:
21
+ for hit in client.search.semantic("agent memory", limit=5):
22
+ print(hit.url)
23
+ ```
24
+
25
+ use it when the agent *is* your code: tool functions in an agent framework, batch curation jobs, anything where you want validation errors before the network and real types after it. this is also the surface to build new surfaces from — the mcp server below is ~40 lines over it.
26
+
27
+ ## the cli
28
+
29
+ `semble` is machine-readable by default, which makes it an agent tool as much as a human one: lists are ndjson, single results are one json object, keys are the api's camelCase. no flags needed to make it parseable — `--pretty` is the opt-in for humans, not the other way around.
30
+
31
+ ```bash
32
+ semble search "durable execution" | jq -r '.url'
33
+ semble add https://example.com --note "worth a read"
34
+ semble library pdewey.com
35
+ ```
36
+
37
+ if your agent already has shell access, this is usually the right surface: zero integration work, composes with `jq`/`xargs`/everything, and each invocation is a fresh process with no session to manage. an agent that can run bash needs nothing else to read and write semble.
38
+
39
+ ## the mcp server
40
+
41
+ `semble-mcp` exposes the sdk to mcp clients via [fastmcp code mode](https://gofastmcp.com/servers/transforms/code-mode). instead of one tool per endpoint (which puts ~50 schemas in the model's context before any work happens), clients see three meta-tools:
42
+
43
+ - `search` — find sdk methods by keyword
44
+ - `get_schema` — fetch parameter schemas for chosen methods
45
+ - `execute` — run model-written python in a [monty](https://github.com/pydantic/monty) sandbox, composing methods via `await call_tool("cards_search", {...})`
46
+
47
+ the payoff is composition without context cost: a workflow like "semantic search, then fetch who saved each hit, then summarize" is one `execute` block and one result in context, instead of n tool round-trips each hauling intermediate json through the model. params use the sdk's snake_case names (discover them via `get_schema` — don't guess), and mistakes come back as precise pydantic validation errors the model can fix in-loop, before anything hits the network.
48
+
49
+ prefer it when:
50
+
51
+ - the client has no shell (claude desktop, cursor, most hosted agents) — mcp is the only door, and three tools beat fifty
52
+ - workflows are composition-heavy and context economy matters, even if a shell exists
53
+ - you want the tool surface to track the sdk automatically — the server reflects over the client, so new sdk methods appear without anyone maintaining tool definitions
54
+
55
+ know the caveats: mcp client quality varies a lot in practice (see [mcpval](https://dev-log.prefect.io/mcpval/) — judge a server by what your client can actually accomplish with it, not by what it exposes), and the server is stdio + env-var auth today, launched per-user by the client rather than hosted.
56
+
57
+ ## choosing
58
+
59
+ the short version: **shell → cli, your own python → sdk, neither → mcp.** code mode is the exception that can earn its place alongside a shell, when multi-call composition would otherwise drag intermediate results through context.
60
+
61
+ one thing that is *not* a differentiator: capability. all three surfaces carry the full read/write power of the api key behind them. pick by runtime fit, not by trying to use the surface as a permission boundary — if you want an agent restricted to reads, give it no key (public reads work) rather than a narrower surface.
62
+
63
+ ## writes are public
64
+
65
+ semble is a social knowledge graph on atproto: cards, notes, collections, and connections your agent creates are real records, visible to the network and attributed to the account behind the key. that's the point — shared curation trails are what make the graph worth reading — but it means an agent writing to semble is publishing, not journaling.
66
+
67
+ a few norms keep the graph high-signal:
68
+
69
+ - write with intent: a url with a note saying *why* beats ten bare urls
70
+ - curate into collections rather than dumping into the library root
71
+ - connections (`supports`, `opposes`, `explains`, ...) are claims — make them when the relationship is real, not to inflate linkage
72
+ - cleanup is part of write access: `remove_from_library`, `collections.delete`, and friends work the same as the adds
73
+
74
+ for the wider picture of agents as curators, see [semble + agents](https://nate.leaflet.pub/3mnxnia4lvk2n).
@@ -4,7 +4,7 @@ dynamic = ["version"]
4
4
  description = "python client for the semble api"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "zzstoatzz", email = "thrast36@gmail.com" }]
7
- requires-python = ">=3.10"
7
+ requires-python = ">=3.12"
8
8
  license = "MIT"
9
9
 
10
10
  keywords = ["semble", "atproto", "bookmarks", "curation", "knowledge-graph"]
@@ -14,8 +14,6 @@ classifiers = [
14
14
  "Intended Audience :: Developers",
15
15
  "License :: OSI Approved :: MIT License",
16
16
  "Programming Language :: Python :: 3",
17
- "Programming Language :: Python :: 3.10",
18
- "Programming Language :: Python :: 3.11",
19
17
  "Programming Language :: Python :: 3.12",
20
18
  "Programming Language :: Python :: 3.13",
21
19
  "Programming Language :: Python :: 3.14",
@@ -111,4 +109,4 @@ exclude = [
111
109
  ]
112
110
 
113
111
  [tool.ty.environment]
114
- python-version = "3.10"
112
+ python-version = "3.12"
@@ -1,10 +1,10 @@
1
1
  from types import TracebackType
2
- from typing import Any
2
+ from typing import Any, overload
3
3
 
4
4
  import httpx2 as httpx
5
- from pydantic import SecretStr
5
+ from pydantic import BaseModel, SecretStr
6
6
 
7
- from semble._exceptions import status_error
7
+ from semble._exceptions import SembleError, status_error
8
8
  from semble.resources.actors import Actors, AsyncActors
9
9
  from semble.resources.cards import AsyncCards, Cards
10
10
  from semble.resources.collections import AsyncCollections, Collections
@@ -16,6 +16,25 @@ from semble.resources.search import AsyncSearch, Search
16
16
  from semble.settings import SembleSettings
17
17
 
18
18
 
19
+ @overload
20
+ def _parse[T: BaseModel](response: httpx.Response, cast_to: type[T]) -> T: ...
21
+ @overload
22
+ def _parse(response: httpx.Response, cast_to: None) -> Any: ...
23
+ def _parse[T: BaseModel](response: httpx.Response, cast_to: type[T] | None) -> Any:
24
+ if not response.is_success:
25
+ raise status_error(response)
26
+ if not response.content:
27
+ if cast_to is not None:
28
+ raise SembleError(
29
+ f"expected a json body from {response.request.url}, got an empty response"
30
+ )
31
+ return None
32
+ data = response.json()
33
+ if cast_to is None:
34
+ return data
35
+ return cast_to.model_validate(data)
36
+
37
+
19
38
  class _BaseClient:
20
39
  def __init__(
21
40
  self,
@@ -42,17 +61,6 @@ class _BaseClient:
42
61
  headers["x-api-key"] = self.api_key.get_secret_value()
43
62
  return headers
44
63
 
45
- @staticmethod
46
- def _parse(response: httpx.Response, cast_to: Any) -> Any:
47
- if not response.is_success:
48
- raise status_error(response)
49
- if not response.content:
50
- return None
51
- data = response.json()
52
- if cast_to is None:
53
- return data
54
- return cast_to.model_validate(data)
55
-
56
64
 
57
65
  class Semble(_BaseClient):
58
66
  """synchronous client for the semble api.
@@ -88,29 +96,45 @@ class Semble(_BaseClient):
88
96
  self.notifications = Notifications(self)
89
97
  self.search = Search(self)
90
98
 
99
+ @overload
100
+ def get[T: BaseModel](
101
+ self, nsid: str, params: dict[str, Any] | None = None, *, cast_to: type[T]
102
+ ) -> T: ...
103
+ @overload
91
104
  def get(
105
+ self, nsid: str, params: dict[str, Any] | None = None, *, cast_to: None = None
106
+ ) -> Any: ...
107
+ def get[T: BaseModel](
92
108
  self,
93
109
  nsid: str,
94
110
  params: dict[str, Any] | None = None,
95
111
  *,
96
- cast_to: Any = None,
112
+ cast_to: type[T] | None = None,
97
113
  ) -> Any:
98
114
  """GET an xrpc query by nsid. escape hatch for unwrapped endpoints."""
99
115
  response = self._http.get(
100
116
  self._url(nsid), params=params, headers=self._headers()
101
117
  )
102
- return self._parse(response, cast_to)
118
+ return _parse(response, cast_to)
103
119
 
120
+ @overload
121
+ def post[T: BaseModel](
122
+ self, nsid: str, json: dict[str, Any] | None = None, *, cast_to: type[T]
123
+ ) -> T: ...
124
+ @overload
104
125
  def post(
126
+ self, nsid: str, json: dict[str, Any] | None = None, *, cast_to: None = None
127
+ ) -> Any: ...
128
+ def post[T: BaseModel](
105
129
  self,
106
130
  nsid: str,
107
131
  json: dict[str, Any] | None = None,
108
132
  *,
109
- cast_to: Any = None,
133
+ cast_to: type[T] | None = None,
110
134
  ) -> Any:
111
135
  """POST an xrpc procedure by nsid. escape hatch for unwrapped endpoints."""
112
136
  response = self._http.post(self._url(nsid), json=json, headers=self._headers())
113
- return self._parse(response, cast_to)
137
+ return _parse(response, cast_to)
114
138
 
115
139
  def close(self) -> None:
116
140
  if self._owns_http:
@@ -162,31 +186,47 @@ class AsyncSemble(_BaseClient):
162
186
  self.notifications = AsyncNotifications(self)
163
187
  self.search = AsyncSearch(self)
164
188
 
189
+ @overload
190
+ async def get[T: BaseModel](
191
+ self, nsid: str, params: dict[str, Any] | None = None, *, cast_to: type[T]
192
+ ) -> T: ...
193
+ @overload
165
194
  async def get(
195
+ self, nsid: str, params: dict[str, Any] | None = None, *, cast_to: None = None
196
+ ) -> Any: ...
197
+ async def get[T: BaseModel](
166
198
  self,
167
199
  nsid: str,
168
200
  params: dict[str, Any] | None = None,
169
201
  *,
170
- cast_to: Any = None,
202
+ cast_to: type[T] | None = None,
171
203
  ) -> Any:
172
204
  """GET an xrpc query by nsid. escape hatch for unwrapped endpoints."""
173
205
  response = await self._http.get(
174
206
  self._url(nsid), params=params, headers=self._headers()
175
207
  )
176
- return self._parse(response, cast_to)
208
+ return _parse(response, cast_to)
177
209
 
210
+ @overload
211
+ async def post[T: BaseModel](
212
+ self, nsid: str, json: dict[str, Any] | None = None, *, cast_to: type[T]
213
+ ) -> T: ...
214
+ @overload
178
215
  async def post(
216
+ self, nsid: str, json: dict[str, Any] | None = None, *, cast_to: None = None
217
+ ) -> Any: ...
218
+ async def post[T: BaseModel](
179
219
  self,
180
220
  nsid: str,
181
221
  json: dict[str, Any] | None = None,
182
222
  *,
183
- cast_to: Any = None,
223
+ cast_to: type[T] | None = None,
184
224
  ) -> Any:
185
225
  """POST an xrpc procedure by nsid. escape hatch for unwrapped endpoints."""
186
226
  response = await self._http.post(
187
227
  self._url(nsid), json=json, headers=self._headers()
188
228
  )
189
- return self._parse(response, cast_to)
229
+ return _parse(response, cast_to)
190
230
 
191
231
  async def close(self) -> None:
192
232
  if self._owns_http:
@@ -7,7 +7,7 @@ never break parsing.
7
7
  """
8
8
 
9
9
  from datetime import datetime
10
- from typing import Any, Generic, Literal, TypeVar
10
+ from typing import Any, Literal
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, Field, model_validator
13
13
  from pydantic.alias_generators import to_camel
@@ -220,8 +220,6 @@ class CountResponse(Model):
220
220
  unread_count: int | None = None
221
221
 
222
222
 
223
- ItemT = TypeVar("ItemT")
224
-
225
223
  # the api names its result array differently per endpoint (cards, users,
226
224
  # activities, ...). collect whichever is present into `items` so every
227
225
  # paginated response has one stable shape.
@@ -240,7 +238,7 @@ _ITEM_KEYS = (
240
238
  )
241
239
 
242
240
 
243
- class Page(Model, Generic[ItemT]):
241
+ class Page[ItemT](Model):
244
242
  items: list[ItemT] = Field(default_factory=list)
245
243
  pagination: Pagination | None = None
246
244
  sorting: Any = None
@@ -9,11 +9,34 @@ from semble import (
9
9
  NotFoundError,
10
10
  RateLimitError,
11
11
  Semble,
12
+ SembleError,
12
13
  ServerError,
13
14
  )
15
+ from semble.types import CountResponse
14
16
  from tests.conftest import AsyncClientFactory, SyncClientFactory
15
17
 
16
18
 
19
+ def empty_response_client() -> Semble:
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ return httpx.Response(200)
22
+
23
+ return Semble(
24
+ api_key="sk_test",
25
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)),
26
+ )
27
+
28
+
29
+ def test_empty_body_without_cast_to_returns_none() -> None:
30
+ assert empty_response_client().post("network.cosmik.card.updateNote", {}) is None
31
+
32
+
33
+ def test_empty_body_with_cast_to_raises() -> None:
34
+ with pytest.raises(SembleError, match="expected a json body"):
35
+ empty_response_client().get(
36
+ "network.cosmik.notification.getUnreadCount", cast_to=CountResponse
37
+ )
38
+
39
+
17
40
  def test_api_key_header(sync_client: SyncClientFactory) -> None:
18
41
  client, recorder = sync_client({"count": 0})
19
42
  client.notifications.get_unread_count()